diff --git a/.gitignore b/.gitignore index 31926cc..0014bfa 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ *.sqlite3 .DS_Store .idea/* +.nova .venv .vscode /*.env* diff --git a/activities/services/post.py b/activities/services/post.py index dbfc837..035498f 100644 --- a/activities/services/post.py +++ b/activities/services/post.py @@ -1,5 +1,7 @@ import logging +from django.db.models import OuterRef + from activities.models import ( Post, PostInteraction, @@ -18,11 +20,11 @@ class PostService: """ @classmethod - def queryset(cls): + def queryset(cls, include_reply_to_author=False): """ Returns the base queryset to use for fetching posts efficiently. """ - return ( + qs = ( Post.objects.not_hidden() .prefetch_related( "attachments", @@ -34,6 +36,13 @@ class PostService: "author__domain", ) ) + if include_reply_to_author: + qs = qs.annotate( + in_reply_to_author_id=Post.objects.filter( + object_uri=OuterRef("in_reply_to") + ).values("author_id")[:1] + ) + return qs def __init__(self, post: Post): self.post = post diff --git a/activities/services/timeline.py b/activities/services/timeline.py index 925606a..d73cd02 100644 --- a/activities/services/timeline.py +++ b/activities/services/timeline.py @@ -8,7 +8,8 @@ from activities.models import ( TimelineEvent, ) from activities.services import PostService -from users.models import Identity +from users.models import Identity, List +from users.services import IdentityService class TimelineService: @@ -152,3 +153,30 @@ class TimelineService: .filter(bookmarks__identity=self.identity) .order_by("-id") ) + + def for_list(self, alist: List) -> models.QuerySet[Post]: + """ + Return posts from members of `alist`, filtered by the lists replies policy. + """ + assert self.identity # Appease mypy + # We only need to include this if we need to filter on it. + include_author = alist.replies_policy == "followed" + members = alist.members.all() + queryset = PostService.queryset(include_reply_to_author=include_author) + match alist.replies_policy: + case "list": + # The default is to show posts (and replies) from list members. + criteria = models.Q(author__in=members) + case "none": + # Don't show any replies, just original posts from list members. + criteria = models.Q(author__in=members) & models.Q( + in_reply_to__isnull=True + ) + case "followed": + # Show posts from list members OR from accounts you follow replying to + # posts by list members. + criteria = models.Q(author__in=members) | ( + models.Q(author__in=IdentityService(self.identity).following()) + & models.Q(in_reply_to_author_id__in=members) + ) + return queryset.filter(criteria).order_by("-id") diff --git a/api/schemas.py b/api/schemas.py index 0f0441c..07d95f8 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -407,11 +407,15 @@ class Announcement(Schema): class List(Schema): id: str title: str - replies_policy: Literal[ - "followed", - "list", - "none", - ] + replies_policy: Literal["followed", "list", "none"] + exclusive: bool + + @classmethod + def from_list( + cls, + list_instance: users_models.List, + ) -> "List": + return cls(**list_instance.to_mastodon_json()) class Preferences(Schema): @@ -503,3 +507,16 @@ class PushSubscription(Schema): return value else: return None + + +class Marker(Schema): + last_read_id: str + version: int + updated_at: str + + @classmethod + def from_marker( + cls, + marker: users_models.Marker, + ) -> "Marker": + return cls(**marker.to_mastodon_json()) diff --git a/api/urls.py b/api/urls.py index 842aec8..be83e88 100644 --- a/api/urls.py +++ b/api/urls.py @@ -11,6 +11,7 @@ from api.views import ( follow_requests, instance, lists, + markers, media, notifications, polls, @@ -43,6 +44,7 @@ urlpatterns = [ path("v1/accounts//following", accounts.account_following), path("v1/accounts//followers", accounts.account_followers), path("v1/accounts//featured_tags", accounts.account_featured_tags), + path("v1/accounts//lists", accounts.account_lists), # Announcements path("v1/announcements", announcements.announcement_list), path("v1/announcements//dismiss", announcements.announcement_dismiss), @@ -66,7 +68,37 @@ urlpatterns = [ path("v1/instance/peers", instance.peers), path("v2/instance", instance.instance_info_v2), # Lists - path("v1/lists", lists.get_lists), + path( + "v1/lists", + methods( + get=lists.get_lists, + post=lists.create_list, + ), + ), + path( + "v1/lists/", + methods( + get=lists.get_list, + put=lists.update_list, + delete=lists.delete_list, + ), + ), + path( + "v1/lists//accounts", + methods( + get=lists.get_accounts, + post=lists.add_accounts, + delete=lists.delete_accounts, + ), + ), + # Markers + path( + "v1/markers", + methods( + get=markers.markers, + post=markers.set_markers, + ), + ), # Media path("v1/media", media.upload_media), path("v2/media", media.upload_media), @@ -125,6 +157,7 @@ urlpatterns = [ path("v1/timelines/home", timelines.home), path("v1/timelines/public", timelines.public), path("v1/timelines/tag/", timelines.hashtag), + path("v1/timelines/list/", timelines.list_timeline), path("v1/conversations", timelines.conversations), path("v1/favourites", timelines.favourites), # Trends diff --git a/api/views/accounts.py b/api/views/accounts.py index 629051f..44bb4b7 100644 --- a/api/views/accounts.py +++ b/api/views/accounts.py @@ -373,3 +373,15 @@ def account_followers( def account_featured_tags(request: HttpRequest, id: str) -> list[schemas.FeaturedTag]: # Not implemented yet return [] + + +@scope_required("read:lists") +@api_view.get +def account_lists(request: HttpRequest, id: str) -> list[schemas.List]: + identity = get_object_or_404( + Identity.objects.exclude(restriction=Identity.Restriction.blocked), pk=id + ) + return [ + schemas.List.from_list(lst) + for lst in request.identity.lists.filter(members=identity) + ] diff --git a/api/views/lists.py b/api/views/lists.py index 2ff10d2..b32e3d8 100644 --- a/api/views/lists.py +++ b/api/views/lists.py @@ -1,12 +1,95 @@ +from typing import Literal + from django.http import HttpRequest -from hatchway import api_view +from django.shortcuts import get_object_or_404 +from hatchway import Schema, api_view from api import schemas from api.decorators import scope_required +class CreateList(Schema): + title: str + replies_policy: Literal["followed", "list", "none"] = "list" + exclusive: bool = False + + +class UpdateList(Schema): + title: str | None + replies_policy: Literal["followed", "list", "none"] | None + exclusive: bool | None + + @scope_required("read:lists") @api_view.get def get_lists(request: HttpRequest) -> list[schemas.List]: - # We don't implement this yet - return [] + return [schemas.List.from_list(lst) for lst in request.identity.lists.all()] + + +@scope_required("write:lists") +@api_view.post +def create_list(request: HttpRequest, data: CreateList) -> schemas.List: + created = request.identity.lists.create( + title=data.title, + replies_policy=data.replies_policy, + exclusive=data.exclusive, + ) + return schemas.List.from_list(created) + + +@scope_required("read:lists") +@api_view.get +def get_list(request: HttpRequest, id: str) -> schemas.List: + alist = get_object_or_404(request.identity.lists, pk=id) + return schemas.List.from_list(alist) + + +@scope_required("write:lists") +@api_view.put +def update_list(request: HttpRequest, id: str, data: UpdateList) -> schemas.List: + alist = get_object_or_404(request.identity.lists, pk=id) + if data.title: + alist.title = data.title + if data.replies_policy: + alist.replies_policy = data.replies_policy + if data.exclusive is not None: + alist.exclusive = data.exclusive + alist.save() + return schemas.List.from_list(alist) + + +@scope_required("write:lists") +@api_view.delete +def delete_list(request: HttpRequest, id: str) -> dict: + alist = get_object_or_404(request.identity.lists, pk=id) + alist.delete() + return {} + + +@scope_required("write:lists") +@api_view.get +def get_accounts(request: HttpRequest, id: str) -> list[schemas.Account]: + alist = get_object_or_404(request.identity.lists, pk=id) + return [schemas.Account.from_identity(ident) for ident in alist.members.all()] + + +@scope_required("write:lists") +@api_view.post +def add_accounts(request: HttpRequest, id: str) -> dict: + alist = get_object_or_404(request.identity.lists, pk=id) + add_ids = request.PARAMS.get("account_ids") + for follow in request.identity.outbound_follows.filter( + target__id__in=add_ids + ).select_related("target"): + alist.members.add(follow.target) + return {} + + +@scope_required("write:lists") +@api_view.delete +def delete_accounts(request: HttpRequest, id: str) -> dict: + alist = get_object_or_404(request.identity.lists, pk=id) + remove_ids = request.PARAMS.get("account_ids") + for ident in alist.members.filter(id__in=remove_ids): + alist.members.remove(ident) + return {} diff --git a/api/views/markers.py b/api/views/markers.py new file mode 100644 index 0000000..1af22ef --- /dev/null +++ b/api/views/markers.py @@ -0,0 +1,36 @@ +from django.http import HttpRequest +from hatchway import api_view + +from api import schemas +from api.decorators import scope_required + + +@scope_required("read:statuses") +@api_view.get +def markers(request: HttpRequest) -> dict[str, schemas.Marker]: + timelines = set(request.PARAMS.getlist("timeline[]")) + data = {} + for m in request.identity.markers.filter(timeline__in=timelines): + data[m.timeline] = schemas.Marker.from_marker(m) + return data + + +@scope_required("write:statuses") +@api_view.post +def set_markers(request: HttpRequest) -> dict[str, schemas.Marker]: + markers = {} + for key, last_id in request.PARAMS.items(): + if not key.endswith("[last_read_id]"): + continue + timeline = key.replace("[last_read_id]", "") + marker, created = request.identity.markers.get_or_create( + timeline=timeline, + defaults={ + "last_read_id": last_id, + }, + ) + if not created: + marker.last_read_id = last_id + marker.save() + markers[timeline] = schemas.Marker.from_marker(marker) + return markers diff --git a/api/views/oauth.py b/api/views/oauth.py index a7d67d3..51fd893 100644 --- a/api/views/oauth.py +++ b/api/views/oauth.py @@ -1,5 +1,4 @@ import base64 -import json import secrets import time from urllib.parse import urlparse, urlunparse @@ -41,19 +40,6 @@ class OauthRedirect(HttpResponseRedirect): super().__init__(urlunparse(url_parts)) -def get_json_and_formdata(request): - # Did they submit JSON? - if request.content_type == "application/json" and request.body.strip(): - return json.loads(request.body) - # Fall back to form data - value = {} - for key, item in request.POST.items(): - value[key] = item - for key, item in request.GET.items(): - value[key] = item - return value - - class AuthorizationView(LoginRequiredMixin, View): """ Asks the user to authorize access. @@ -106,7 +92,7 @@ class AuthorizationView(LoginRequiredMixin, View): return render(request, "api/oauth_authorize.html", context) def post(self, request): - post_data = get_json_and_formdata(request) + post_data = request.PARAMS # Grab the application and other details again redirect_uri = post_data["redirect_uri"] scope = post_data["scope"] @@ -160,7 +146,7 @@ class TokenView(View): ) def post(self, request): - post_data = get_json_and_formdata(request) + post_data = request.PARAMS.copy() auth_client_id, auth_client_secret = extract_client_info_from_basic_auth( request ) @@ -243,7 +229,7 @@ class TokenView(View): @method_decorator(csrf_exempt, name="dispatch") class RevokeTokenView(View): def post(self, request): - post_data = get_json_and_formdata(request) + post_data = request.PARAMS.copy() auth_client_id, auth_client_secret = extract_client_info_from_basic_auth( request ) diff --git a/api/views/timelines.py b/api/views/timelines.py index 9f4ed5a..6e96d85 100644 --- a/api/views/timelines.py +++ b/api/views/timelines.py @@ -1,4 +1,5 @@ from django.http import HttpRequest +from django.shortcuts import get_object_or_404 from hatchway import ApiError, ApiResponse, api_view from activities.models import Post, TimelineEvent @@ -159,3 +160,31 @@ def favourites( request=request, include_params=["limit"], ) + + +@scope_required("read:lists") +@api_view.get +def list_timeline( + request: HttpRequest, + list_id: str, + max_id: str | None = None, + since_id: str | None = None, + min_id: str | None = None, + limit: int = 20, +) -> ApiResponse[list[schemas.Status]]: + alist = get_object_or_404(request.identity.lists, pk=list_id) + queryset = TimelineService(request.identity).for_list(alist) + + paginator = MastodonPaginator() + pager: PaginationResult[Post] = paginator.paginate( + queryset, + min_id=min_id, + max_id=max_id, + since_id=since_id, + limit=limit, + ) + return PaginatingApiResponse( + schemas.Status.map_from_post(pager.results, request.identity), + request=request, + include_params=["limit"], + ) diff --git a/core/middleware.py b/core/middleware.py index db60559..4121623 100644 --- a/core/middleware.py +++ b/core/middleware.py @@ -1,3 +1,4 @@ +import json from time import time from django.conf import settings @@ -73,3 +74,25 @@ def show_toolbar(request): Determines whether to show the debug toolbar on a given page. """ return settings.DEBUG and request.user.is_authenticated and request.user.admin + + +class ParamsMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def make_params(self, request): + # See https://docs.joinmastodon.org/client/intro/#parameters + # If they sent JSON, use that. + if request.content_type == "application/json" and request.body.strip(): + return json.loads(request.body) + # Otherwise, fall back to form data. + params = {} + for key, value in request.GET.items(): + params[key] = value + for key, value in request.POST.items(): + params[key] = value + return params + + def __call__(self, request): + request.PARAMS = self.make_params(request) + return self.get_response(request) diff --git a/docs/features.rst b/docs/features.rst index 0cbaf69..2305a58 100644 --- a/docs/features.rst +++ b/docs/features.rst @@ -9,6 +9,7 @@ Currently, it supports: * A web UI (which can be installed as a PWA as well) * Mastodon-compatible client applications (beta support) * Posts with content warnings and visibilities including a local-only option +* Creating polls on posts * Editing post content * Viewing images, videos and other post attachments * Uploading images and attaching image captions @@ -28,6 +29,8 @@ Currently, it supports: * Server defederation (blocking) * Signup flow, including auto-cap by user numbers and invite system * Password reset via email +* Bookmarks +* Markers Features planned for releases up to 1.0: @@ -41,9 +44,7 @@ Features planned for releases up to 1.0: Features that may make it into 1.0, or might be further out: -* Creating polls on posts * Filters -* Bookmarks * Lists * Scheduling posts * Mastodon-compatible account migration target/source diff --git a/docs/releases/next.rst b/docs/releases/next.rst index 207ff69..b57d036 100644 --- a/docs/releases/next.rst +++ b/docs/releases/next.rst @@ -13,3 +13,17 @@ variables. You can generate a keypair via `https://web-push-codelab.glitch.me/`_ Note that users of apps may need to sign out and in again to their accounts for the app to notice that it can now do push notifications. Some apps, like Elk, may cache the fact your server didn't support it for a while. + + +Marker Support +~~~~~~~~~~~~~~ + +Takahē now supports the `Markers API `_, +used by clients to sync read positions within timelines. + + +Lists Support +~~~~~~~~~~~~~ + +Takahē now supports the `Lists APIs `_, +used by clients to maintain lists of accounts to show timelines for. diff --git a/takahe/settings.py b/takahe/settings.py index 2bd43b2..6fc9f9a 100644 --- a/takahe/settings.py +++ b/takahe/settings.py @@ -233,6 +233,7 @@ MIDDLEWARE = [ "django_htmx.middleware.HtmxMiddleware", "core.middleware.HeadersMiddleware", "core.middleware.ConfigLoadingMiddleware", + "core.middleware.ParamsMiddleware", "api.middleware.ApiTokenMiddleware", "users.middleware.DomainMiddleware", ] diff --git a/users/admin.py b/users/admin.py index 4d920f2..2cd5fee 100644 --- a/users/admin.py +++ b/users/admin.py @@ -13,6 +13,8 @@ from users.models import ( Identity, InboxMessage, Invite, + List, + Marker, PasswordReset, Report, User, @@ -212,6 +214,17 @@ class InviteAdmin(admin.ModelAdmin): list_display = ["id", "created", "token", "note"] +@admin.register(List) +class ListAdmin(admin.ModelAdmin): + list_display = ["id", "identity", "title", "replies_policy", "exclusive"] + autocomplete_fields = ["members"] + + +@admin.register(Marker) +class MarkerAdmin(admin.ModelAdmin): + list_display = ["id", "identity", "timeline", "last_read_id", "updated_at"] + + @admin.register(Report) class ReportAdmin(admin.ModelAdmin): list_display = ["id", "created", "resolved", "type", "subject_identity"] diff --git a/users/migrations/0023_marker.py b/users/migrations/0023_marker.py new file mode 100644 index 0000000..32d8e1e --- /dev/null +++ b/users/migrations/0023_marker.py @@ -0,0 +1,41 @@ +# Generated by Django 4.2.11 on 2024-04-17 03:09 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0022_follow_request"), + ] + + operations = [ + migrations.CreateModel( + name="Marker", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("timeline", models.CharField(max_length=100)), + ("last_read_id", models.CharField(max_length=200)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "identity", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="markers", + to="users.identity", + ), + ), + ], + options={ + "unique_together": {("identity", "timeline")}, + }, + ), + ] diff --git a/users/migrations/0024_list.py b/users/migrations/0024_list.py new file mode 100644 index 0000000..0a5e0cb --- /dev/null +++ b/users/migrations/0024_list.py @@ -0,0 +1,54 @@ +# Generated by Django 4.2.11 on 2024-04-19 01:03 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0023_marker"), + ] + + operations = [ + migrations.CreateModel( + name="List", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=200)), + ( + "replies_policy", + models.CharField( + choices=[ + ("followed", "Followed"), + ("list", "List Only"), + ("none", "None"), + ], + max_length=10, + ), + ), + ("exclusive", models.BooleanField()), + ( + "identity", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="lists", + to="users.identity", + ), + ), + ( + "members", + models.ManyToManyField( + blank=True, related_name="in_lists", to="users.identity" + ), + ), + ], + ), + ] diff --git a/users/models/__init__.py b/users/models/__init__.py index 8396e42..40d1885 100644 --- a/users/models/__init__.py +++ b/users/models/__init__.py @@ -7,6 +7,8 @@ from .hashtag_follow import HashtagFollow # noqa from .identity import Identity, IdentityStates # noqa from .inbox_message import InboxMessage, InboxMessageStates # noqa from .invite import Invite # noqa +from .lists import List # noqa +from .marker import Marker # noqa from .password_reset import PasswordReset # noqa from .report import Report # noqa from .system_actor import SystemActor # noqa diff --git a/users/models/lists.py b/users/models/lists.py new file mode 100644 index 0000000..2daf25e --- /dev/null +++ b/users/models/lists.py @@ -0,0 +1,37 @@ +from django.db import models + + +class List(models.Model): + """ + A list of accounts. + """ + + class RepliesPolicy(models.TextChoices): + followed = "followed" + list_only = "list" + none = "none" + + identity = models.ForeignKey( + "users.Identity", + on_delete=models.CASCADE, + related_name="lists", + ) + title = models.CharField(max_length=200) + replies_policy = models.CharField(max_length=10, choices=RepliesPolicy.choices) + exclusive = models.BooleanField() + members = models.ManyToManyField( + "users.Identity", + related_name="in_lists", + blank=True, + ) + + def __str__(self): + return f"#{self.id}: {self.identity} → {self.title}" + + def to_mastodon_json(self): + return { + "id": str(self.id), + "title": self.title, + "replies_policy": self.replies_policy, + "exclusive": self.exclusive, + } diff --git a/users/models/marker.py b/users/models/marker.py new file mode 100644 index 0000000..f18bedb --- /dev/null +++ b/users/models/marker.py @@ -0,0 +1,31 @@ +from django.db import models + +from core.ld import format_ld_date + + +class Marker(models.Model): + """ + A timeline marker. + """ + + identity = models.ForeignKey( + "users.Identity", + on_delete=models.CASCADE, + related_name="markers", + ) + timeline = models.CharField(max_length=100) + last_read_id = models.CharField(max_length=200) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = [("identity", "timeline")] + + def __str__(self): + return f"#{self.id}: {self.identity} → {self.timeline}[{self.last_read_id}]" + + def to_mastodon_json(self): + return { + "last_read_id": self.last_read_id, + "version": 0, + "updated_at": format_ld_date(self.updated_at), + }