diff --git a/activities/models/post.py b/activities/models/post.py index d5dca88..273586d 100644 --- a/activities/models/post.py +++ b/activities/models/post.py @@ -900,7 +900,13 @@ class Post(StatorModel): if mention.username ], "tags": ( - [{"name": tag, "url": "/tag/{tag}/"} for tag in self.hashtags] + [ + { + "name": tag, + "url": f"https://{self.author.domain.uri_domain}/tags/{tag}/", + } + for tag in self.hashtags + ] if self.hashtags else [] ), diff --git a/api/pagination.py b/api/pagination.py index 6bd02c9..8ebbd92 100644 --- a/api/pagination.py +++ b/api/pagination.py @@ -1,4 +1,50 @@ +import dataclasses +import urllib.parse + from django.db import models +from django.http import HttpRequest + + +@dataclasses.dataclass +class PaginationResult: + #: A list of objects that matched the pagination query. + results: list[models.Model] + #: The actual applied limit, which may be different from what was requested. + limit: int + sort_attribute: str + + def next(self, request: HttpRequest, allowed_params: list[str]): + """ + Returns a URL to the next page of results. + """ + if not self.results: + return None + + params = self.filter_params(request, allowed_params) + params["max_id"] = self.results[-1].pk + + return f"{request.build_absolute_uri(request.path)}?{urllib.parse.urlencode(params)}" + + def prev(self, request: HttpRequest, allowed_params: list[str]): + """ + Returns a URL to the previous page of results. + """ + if not self.results: + return None + + params = self.filter_params(request, allowed_params) + params["min_id"] = self.results[0].pk + + return f"{request.build_absolute_uri(request.path)}?{urllib.parse.urlencode(params)}" + + @staticmethod + def filter_params(request: HttpRequest, allowed_params: list[str]): + params = {} + for key in allowed_params: + value = request.GET.get(key, None) + if value: + params[key] = value + return params class MastodonPaginator: @@ -34,6 +80,7 @@ class MastodonPaginator: queryset = queryset.filter( **{self.sort_attribute + "__lt": getattr(anchor, self.sort_attribute)} ) + if since_id: try: anchor = self.anchor_model.objects.get(pk=since_id) @@ -42,9 +89,10 @@ class MastodonPaginator: queryset = queryset.filter( **{self.sort_attribute + "__gt": getattr(anchor, self.sort_attribute)} ) + if min_id: # Min ID requires items _immediately_ newer than specified, so we - # invert the ordering to accomodate + # invert the ordering to accommodate try: anchor = self.anchor_model.objects.get(pk=min_id) except self.anchor_model.DoesNotExist: @@ -54,4 +102,10 @@ class MastodonPaginator: ).order_by(self.sort_attribute) else: queryset = queryset.order_by("-" + self.sort_attribute) - return list(queryset[: min(limit or self.default_limit, self.max_limit)]) + + limit = min(limit or self.default_limit, self.max_limit) + return PaginationResult( + results=list(queryset[:limit]), + limit=limit, + sort_attribute=self.sort_attribute, + ) diff --git a/api/schemas.py b/api/schemas.py index 727bf4f..9f8541f 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -44,8 +44,8 @@ class Account(Schema): group: bool discoverable: bool moved: Union[None, bool, "Account"] - suspended: bool - limited: bool + suspended: bool = False + limited: bool = False created_at: str last_status_at: str | None = Field(...) statuses_count: int diff --git a/api/views/accounts.py b/api/views/accounts.py index c37db8b..71a5c74 100644 --- a/api/views/accounts.py +++ b/api/views/accounts.py @@ -1,3 +1,4 @@ +from django.http import HttpRequest, HttpResponse from django.shortcuts import get_object_or_404 from ninja import Field @@ -91,7 +92,8 @@ def account(request, id: str): @api_router.get("/v1/accounts/{id}/statuses", response=list[schemas.Status]) @identity_required def account_statuses( - request, + request: HttpRequest, + response: HttpResponse, id: str, exclude_reblogs: bool = False, exclude_replies: bool = False, @@ -119,16 +121,37 @@ def account_statuses( queryset = queryset.filter(attachments__pk__isnull=False) if tagged: queryset = queryset.tagged_with(tagged) + paginator = MastodonPaginator(Post) - posts = paginator.paginate( + pager = paginator.paginate( queryset, min_id=min_id, max_id=max_id, since_id=since_id, limit=limit, ) - interactions = PostInteraction.get_post_interactions(posts, request.identity) - return [post.to_mastodon_json(interactions=interactions) for post in queryset] + + if pager.results: + params = [ + "limit", + "id", + "exclude_reblogs", + "exclude_replies", + "only_media", + "pinned", + "tagged", + ] + response.headers["Link"] = ", ".join( + ( + f'<{pager.next(request, params)}>; rel="next"', + f'<{pager.prev(request, params)}>; rel="prev"', + ) + ) + + interactions = PostInteraction.get_post_interactions( + pager.results, request.identity + ) + return [post.to_mastodon_json(interactions=interactions) for post in pager.results] @api_router.post("/v1/accounts/{id}/follow", response=schemas.Relationship) diff --git a/api/views/notifications.py b/api/views/notifications.py index ac70be6..79a3a7d 100644 --- a/api/views/notifications.py +++ b/api/views/notifications.py @@ -1,3 +1,5 @@ +from django.http import HttpRequest, HttpResponse + from activities.models import PostInteraction, TimelineEvent from activities.services import TimelineService from api import schemas @@ -9,7 +11,8 @@ from api.views.base import api_router @api_router.get("/v1/notifications", response=list[schemas.Notification]) @identity_required def notifications( - request, + request: HttpRequest, + response: HttpResponse, max_id: str | None = None, since_id: str | None = None, min_id: str | None = None, @@ -33,15 +36,27 @@ def notifications( [base_types[r] for r in requested_types] ) paginator = MastodonPaginator(TimelineEvent) - events = paginator.paginate( + pager = paginator.paginate( queryset, min_id=min_id, max_id=max_id, since_id=since_id, limit=limit, ) - interactions = PostInteraction.get_event_interactions(events, request.identity) + + if pager.results: + params = ["limit", "account_id"] + response.headers["Link"] = ", ".join( + ( + f'<{pager.next(request, params)}>; rel="next"', + f'<{pager.prev(request, params)}>; rel="prev"', + ) + ) + + interactions = PostInteraction.get_event_interactions( + pager.results, request.identity + ) return [ event.to_mastodon_notification_json(interactions=interactions) - for event in events + for event in pager.results ] diff --git a/api/views/timelines.py b/api/views/timelines.py index 090fadb..9099768 100644 --- a/api/views/timelines.py +++ b/api/views/timelines.py @@ -1,15 +1,19 @@ +from django.http import HttpRequest, HttpResponse, JsonResponse + from activities.models import Post, PostInteraction from activities.services import TimelineService from api import schemas from api.decorators import identity_required from api.pagination import MastodonPaginator from api.views.base import api_router +from core.models import Config @api_router.get("/v1/timelines/home", response=list[schemas.Status]) @identity_required def home( - request, + request: HttpRequest, + response: HttpResponse, max_id: str | None = None, since_id: str | None = None, min_id: str | None = None, @@ -17,24 +21,35 @@ def home( ): paginator = MastodonPaginator(Post) queryset = TimelineService(request.identity).home() - events = paginator.paginate( + pager = paginator.paginate( queryset, min_id=min_id, max_id=max_id, since_id=since_id, limit=limit, ) - interactions = PostInteraction.get_event_interactions(events, request.identity) + interactions = PostInteraction.get_event_interactions( + pager.results, request.identity + ) + + if pager.results: + response.headers["Link"] = ", ".join( + ( + f"<{pager.next(request, ['limit'])}>; rel=\"next\"", + f"<{pager.prev(request, ['limit'])}>; rel=\"prev\"", + ) + ) + return [ event.subject_post.to_mastodon_json(interactions=interactions) - for event in events + for event in pager.results ] @api_router.get("/v1/timelines/public", response=list[schemas.Status]) -@identity_required def public( - request, + request: HttpRequest, + response: HttpResponse, local: bool = False, remote: bool = False, only_media: bool = False, @@ -43,6 +58,9 @@ def public( min_id: str | None = None, limit: int = 20, ): + if not request.identity and not Config.system.public_timeline: + return JsonResponse({"error": "public timeline is disabled"}, status=422) + if local: queryset = TimelineService(request.identity).local() else: @@ -52,21 +70,34 @@ def public( if only_media: queryset = queryset.filter(attachments__id__isnull=True) paginator = MastodonPaginator(Post) - posts = paginator.paginate( + pager = paginator.paginate( queryset, min_id=min_id, max_id=max_id, since_id=since_id, limit=limit, ) - interactions = PostInteraction.get_post_interactions(posts, request.identity) - return [post.to_mastodon_json(interactions=interactions) for post in posts] + + if pager.results: + params = ["limit", "local", "remote", "only_media"] + response.headers["Link"] = ", ".join( + ( + f'<{pager.next(request, params)}>; rel="next"', + f'<{pager.prev(request, params)}>; rel="prev"', + ) + ) + + interactions = PostInteraction.get_post_interactions( + pager.results, request.identity + ) + return [post.to_mastodon_json(interactions=interactions) for post in pager.results] @api_router.get("/v1/timelines/tag/{hashtag}", response=list[schemas.Status]) @identity_required def hashtag( - request, + request: HttpRequest, + response: HttpResponse, hashtag: str, local: bool = False, only_media: bool = False, @@ -83,21 +114,34 @@ def hashtag( if only_media: queryset = queryset.filter(attachments__id__isnull=True) paginator = MastodonPaginator(Post) - posts = paginator.paginate( + pager = paginator.paginate( queryset, min_id=min_id, max_id=max_id, since_id=since_id, limit=limit, ) - interactions = PostInteraction.get_post_interactions(posts, request.identity) - return [post.to_mastodon_json(interactions=interactions) for post in posts] + + if pager.results: + params = ["limit", "local", "hashtag", "only_media"] + response.headers["Link"] = ", ".join( + ( + f'<{pager.next(request, params)}>; rel="next"', + f'<{pager.prev(request, params)}>; rel="prev"', + ) + ) + + interactions = PostInteraction.get_post_interactions( + pager.results, request.identity + ) + return [post.to_mastodon_json(interactions=interactions) for post in pager.results] @api_router.get("/v1/conversations", response=list[schemas.Status]) @identity_required def conversations( - request, + request: HttpRequest, + response: HttpResponse, max_id: str | None = None, since_id: str | None = None, min_id: str | None = None, diff --git a/core/ld.py b/core/ld.py index df7ce8a..3c37253 100644 --- a/core/ld.py +++ b/core/ld.py @@ -408,7 +408,7 @@ schemas = { }, } -DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ" +DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.Z" DATETIME_TZ_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00" DATETIME_MS_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ" @@ -497,7 +497,10 @@ def get_str_or_id(value: str | dict | None) -> str | None: def format_ld_date(value: datetime.datetime) -> str: - return value.strftime(DATETIME_FORMAT) + # We chop the timestamp to be identical to the timestamps returned by + # Mastodon's API, because some clients like Toot! (for iOS) are especially + # picky about timestamp parsing. + return f"{value.strftime(DATETIME_MS_FORMAT)[:-4]}Z" def parse_ld_date(value: str | None) -> datetime.datetime | None: diff --git a/core/models/config.py b/core/models/config.py index 35eb934..3ccfbf8 100644 --- a/core/models/config.py +++ b/core/models/config.py @@ -221,6 +221,7 @@ class Config(models.Model): identity_max_per_user: int = 5 identity_max_age: int = 24 * 60 * 60 inbox_message_purge_after: int = 24 * 60 * 60 + public_timeline: bool = True hashtag_unreviewed_are_public: bool = True hashtag_stats_max_age: int = 60 * 60 diff --git a/static/img/missing.png b/static/img/missing.png new file mode 100644 index 0000000..26b59e7 Binary files /dev/null and b/static/img/missing.png differ diff --git a/users/models/identity.py b/users/models/identity.py index cce2a2a..546c8f8 100644 --- a/users/models/identity.py +++ b/users/models/identity.py @@ -801,6 +801,8 @@ class Identity(StatorModel): from activities.models import Emoji header_image = self.local_image_url() + missing = StaticAbsoluteUrl("img/missing.png").absolute + metadata_value_text = ( " ".join([m["value"] for m in self.metadata]) if self.metadata else "" ) @@ -810,14 +812,14 @@ class Identity(StatorModel): return { "id": self.pk, "username": self.username or "", - "acct": self.username if self.local else self.handle, + "acct": self.handle, "url": self.absolute_profile_uri() or "", "display_name": self.name or "", "note": self.summary or "", "avatar": self.local_icon_url().absolute, "avatar_static": self.local_icon_url().absolute, - "header": header_image.absolute if header_image else None, - "header_static": header_image.absolute if header_image else None, + "header": header_image.absolute if header_image else missing, + "header_static": header_image.absolute if header_image else missing, "locked": False, "fields": ( [ diff --git a/users/views/admin/settings.py b/users/views/admin/settings.py index 97e91e0..6d6380b 100644 --- a/users/views/admin/settings.py +++ b/users/views/admin/settings.py @@ -51,10 +51,6 @@ class BasicSettings(AdminSettingsPage): "help_text": "Displayed on the homepage and the about page.\nUse Markdown for formatting.", "display": "textarea", }, - "site_frontpage_posts": { - "title": "Show Posts On Front Page", - "help_text": "Whether to show some recent posts on the logged-out homepage.", - }, "site_icon": { "title": "Site Icon", "help_text": "Minimum size 64x64px. Should be square.", @@ -93,13 +89,20 @@ class BasicSettings(AdminSettingsPage): "title": "Unreviewed Emoji Are Public", "help_text": "Public Emoji may appear as images, instead of shortcodes", }, + "public_timeline": { + "title": "Public Timeline", + "help_text": "If enabled, allows anonymous access to the public timeline", + }, + "site_frontpage_posts": { + "title": "Show Public Timeline On Front Page", + "help_text": "Whether to show some recent posts on the logged-out homepage", + }, } layout = { "Branding": [ "site_name", "site_about", - "site_frontpage_posts", "site_icon", "site_banner", "highlight_color", @@ -115,6 +118,10 @@ class BasicSettings(AdminSettingsPage): "hashtag_unreviewed_are_public", "emoji_unreviewed_are_public", ], + "Timelines": [ + "public_timeline", + "site_frontpage_posts", + ], "Identities": [ "identity_max_per_user", "identity_min_length",