diff --git a/.gitignore b/.gitignore index 31926cca5..0014bfa82 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 dbfc837b4..035498f1e 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 @@ def queryset(cls): "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 925606a6b..d73cd0275 100644 --- a/activities/services/timeline.py +++ b/activities/services/timeline.py @@ -8,7 +8,8 @@ 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 @@ def bookmarks(self) -> models.QuerySet[Post]: .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 0f0441c9c..07d95f845 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -407,11 +407,15 @@ def from_announcement( 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 @@ def from_token( 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 842aec809..be83e8872 100644 --- a/api/urls.py +++ b/api/urls.py @@ -11,6 +11,7 @@ follow_requests, instance, lists, + markers, media, notifications, polls, @@ -43,6 +44,7 @@ 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 @@ 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 @@ 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 629051ff9..44bb4b713 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 2ff10d2d6..b32e3d8f8 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 000000000..1af22ef0e --- /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 a7d67d36c..51fd893ff 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 @@ def __init__(self, redirect_uri, **kwargs): 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 @@ def get(self, request): 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 @@ def verify_code( ) 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 @@ def post(self, request): @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 9f4ed5afb..6e96d8588 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 db605598f..412162314 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 0cbaf69ef..2305a5878 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 207ff69ce..b57d036b7 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 2bd43b2da..6fc9f9aed 100644 --- a/takahe/settings.py +++ b/takahe/settings.py @@ -233,6 +233,7 @@ class Config: "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 4d920f272..2cd5fee56 100644 --- a/users/admin.py +++ b/users/admin.py @@ -13,6 +13,8 @@ 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 000000000..32d8e1e02 --- /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 000000000..0a5e0cb15 --- /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 8396e424e..40d1885d6 100644 --- a/users/models/__init__.py +++ b/users/models/__init__.py @@ -7,6 +7,8 @@ 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 000000000..2daf25ee3 --- /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 000000000..f18bedb7d --- /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), + }