From 3e062aed360ca54c26733b175d00d0d4671f3591 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Sun, 11 Dec 2022 00:25:48 -0700 Subject: [PATCH] Timelines working --- activities/models/post.py | 47 ++++++++++++ activities/models/post_attachment.py | 30 +++++++- api/decorators.py | 19 +++++ api/middleware.py | 27 +++++++ api/schemas/__init__.py | 108 +++++++++++++++++++++++++++ api/views/__init__.py | 3 + api/views/accounts.py | 9 +++ api/views/apps.py | 14 +--- api/views/instance.py | 1 - api/views/oauth.py | 4 - api/views/timelines.py | 23 ++++++ takahe/settings.py | 1 + tests/api/test_accounts.py | 12 +++ tests/api/test_instance.py | 11 +++ tests/conftest.py | 21 ++++++ users/middleware.py | 22 ++++-- users/models/identity.py | 45 ++++++++++- 17 files changed, 368 insertions(+), 29 deletions(-) create mode 100644 api/decorators.py create mode 100644 api/middleware.py create mode 100644 api/schemas/__init__.py create mode 100644 api/views/accounts.py create mode 100644 api/views/timelines.py create mode 100644 tests/api/test_accounts.py create mode 100644 tests/api/test_instance.py diff --git a/activities/models/post.py b/activities/models/post.py index b0c89ac..1e372c2 100644 --- a/activities/models/post.py +++ b/activities/models/post.py @@ -708,3 +708,50 @@ class Post(StatorModel): canonicalise(response.json(), include_security=True), update=True, ) + + ### Mastodon API ### + + def to_mastodon_json(self): + reply_parent = None + if self.in_reply_to: + reply_parent = Post.objects.filter(object_uri=self.in_reply_to).first() + return { + "id": self.pk, + "uri": self.object_uri, + "created_at": format_ld_date(self.published), + "account": self.author.to_mastodon_json(), + "content": self.safe_content_remote(), + "visibility": "public", + "sensitive": self.sensitive, + "spoiler_text": self.summary or "", + "media_attachments": [ + attachment.to_mastodon_json() for attachment in self.attachments.all() + ], + "mentions": [ + { + "id": mention.id, + "username": mention.username, + "url": mention.absolute_profile_uri(), + "acct": mention.handle, + } + for mention in self.mentions.all() + ], + "tags": ( + [{"name": tag, "url": "/tag/{tag}/"} for tag in self.hashtags] + if self.hashtags + else [] + ), + "emojis": [], + "reblogs_count": self.interactions.filter(type="boost").count(), + "favourites_count": self.interactions.filter(type="like").count(), + "replies_count": 0, + "url": self.absolute_object_uri(), + "in_reply_to_id": reply_parent.pk if reply_parent else None, + "in_reply_to_account_id": reply_parent.author.pk if reply_parent else None, + "reblog": None, + "poll": None, + "card": None, + "language": None, + "text": self.safe_content_plain(), + "edited_at": format_ld_date(self.edited) if self.edited else None, + } diff --git a/activities/models/post_attachment.py b/activities/models/post_attachment.py index 120a1d1..7b1ad6b 100644 --- a/activities/models/post_attachment.py +++ b/activities/models/post_attachment.py @@ -1,5 +1,6 @@ from functools import partial +from django.conf import settings from django.db import models from core.uploads import upload_namer @@ -77,13 +78,13 @@ class PostAttachment(StatorModel): elif self.file: return self.file.url else: - return f"/proxy/post_attachment/{self.pk}/" + return f"https://{settings.MAIN_DOMAIN}/proxy/post_attachment/{self.pk}/" def full_url(self): if self.file: return self.file.url else: - return f"/proxy/post_attachment/{self.pk}/" + return f"https://{settings.MAIN_DOMAIN}/proxy/post_attachment/{self.pk}/" ### ActivityPub ### @@ -97,3 +98,28 @@ class PostAttachment(StatorModel): "mediaType": self.mimetype, "http://joinmastodon.org/ns#focalPoint": [0, 0], } + + ### Mastodon Client API ### + + def to_mastodon_json(self): + return { + "id": self.pk, + "type": "image" if self.is_image() else "unknown", + "url": self.full_url(), + "preview_url": self.thumbnail_url(), + "remote_url": None, + "meta": { + "original": { + "width": self.width, + "height": self.height, + "size": f"{self.width}x{self.height}", + "aspect": self.width / self.height, + }, + "focus": { + "x": self.focal_x or 0, + "y": self.focal_y or 0, + }, + }, + "description": self.name, + "blurhash": self.blurhash, + } diff --git a/api/decorators.py b/api/decorators.py new file mode 100644 index 0000000..b60cc05 --- /dev/null +++ b/api/decorators.py @@ -0,0 +1,19 @@ +from functools import wraps + +from django.http import JsonResponse + + +def identity_required(function): + """ + API version of the identity_required decorator that just makes sure the + token is tied to one, not an app only. + """ + + @wraps(function) + def inner(request, *args, **kwargs): + # They need an identity + if not request.identity: + return JsonResponse({"error": "identity_token_required"}, status=400) + return function(request, *args, **kwargs) + + return inner diff --git a/api/middleware.py b/api/middleware.py new file mode 100644 index 0000000..84eddca --- /dev/null +++ b/api/middleware.py @@ -0,0 +1,27 @@ +from django.http import HttpResponse + +from api.models import Token + + +class ApiTokenMiddleware: + """ + Adds request.user and request.identity if an API token appears. + Also nukes request.session so it can't be used accidentally. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + auth_header = request.headers.get("authorization", None) + if auth_header and auth_header.startswith("Bearer "): + token_value = auth_header[7:] + try: + token = Token.objects.get(token=token_value) + except Token.DoesNotExist: + return HttpResponse("Invalid Bearer token", status=400) + request.user = token.user + request.identity = token.identity + request.session = None + response = self.get_response(request) + return response diff --git a/api/schemas/__init__.py b/api/schemas/__init__.py new file mode 100644 index 0000000..cc0660c --- /dev/null +++ b/api/schemas/__init__.py @@ -0,0 +1,108 @@ +from typing import Literal, Optional, Union + +from ninja import Field, Schema + + +class Application(Schema): + id: str + name: str + website: str | None + client_id: str + client_secret: str + redirect_uri: str = Field(alias="redirect_uris") + + +class CustomEmoji(Schema): + shortcode: str + url: str + static_url: str + visible_in_picker: bool + category: str + + +class AccountField(Schema): + name: str + value: str + verified_at: str | None + + +class Account(Schema): + id: str + username: str + acct: str + url: str + display_name: str + note: str + avatar: str + avatar_static: str + header: str + header_static: str + locked: bool + fields: list[AccountField] + emojis: list[CustomEmoji] + bot: bool + group: bool + discoverable: bool + moved: Union[None, bool, "Account"] + suspended: bool + limited: bool + created_at: str + last_status_at: str | None = Field(...) + statuses_count: int + followers_count: int + following_count: int + + +class MediaAttachment(Schema): + id: str + type: Literal["unknown", "image", "gifv", "video", "audio"] + url: str + preview_url: str + remote_url: str | None + meta: dict + description: str | None + blurhash: str | None + + +class StatusMention(Schema): + id: str + username: str + url: str + acct: str + + +class StatusTag(Schema): + name: str + url: str + + +class Status(Schema): + id: str + uri: str + created_at: str + account: Account + content: str + visibility: Literal["public", "unlisted", "private", "direct"] + sensitive: bool + spoiler_text: str + media_attachments: list[MediaAttachment] + mentions: list[StatusMention] + tags: list[StatusTag] + emojis: list[CustomEmoji] + reblogs_count: int + favourites_count: int + replies_count: int + url: str | None = Field(...) + in_reply_to_id: str | None = Field(...) + in_reply_to_account_id: str | None = Field(...) + reblog: Optional["Status"] = Field(...) + poll: None = Field(...) + card: None = Field(...) + language: None = Field(...) + text: str | None = Field(...) + edited_at: str | None + favourited: bool | None + reblogged: bool | None + muted: bool | None + bookmarked: bool | None + pinned: bool | None diff --git a/api/views/__init__.py b/api/views/__init__.py index d661e7c..93cf419 100644 --- a/api/views/__init__.py +++ b/api/views/__init__.py @@ -1,3 +1,6 @@ +from .accounts import * # noqa from .apps import * # noqa from .base import api # noqa from .instance import * # noqa +from .oauth import * # noqa +from .timelines import * # noqa diff --git a/api/views/accounts.py b/api/views/accounts.py new file mode 100644 index 0000000..79906dc --- /dev/null +++ b/api/views/accounts.py @@ -0,0 +1,9 @@ +from .. import schemas +from ..decorators import identity_required +from .base import api + + +@api.get("/v1/accounts/verify_credentials", response=schemas.Account) +@identity_required +def verify_credentials(request): + return request.identity.to_mastodon_json() diff --git a/api/views/apps.py b/api/views/apps.py index 33ecf0f..1642ee9 100644 --- a/api/views/apps.py +++ b/api/views/apps.py @@ -1,7 +1,8 @@ import secrets -from ninja import Field, Schema +from ninja import Schema +from .. import schemas from ..models import Application from .base import api @@ -13,16 +14,7 @@ class CreateApplicationSchema(Schema): website: None | str = None -class ApplicationSchema(Schema): - id: str - name: str - website: str | None - client_id: str - client_secret: str - redirect_uri: str = Field(alias="redirect_uris") - - -@api.post("/v1/apps", response=ApplicationSchema) +@api.post("/v1/apps", response=schemas.Application) def add_app(request, details: CreateApplicationSchema): client_id = "tk-" + secrets.token_urlsafe(16) client_secret = secrets.token_urlsafe(40) diff --git a/api/views/instance.py b/api/views/instance.py index 5923d30..eef258d 100644 --- a/api/views/instance.py +++ b/api/views/instance.py @@ -9,7 +9,6 @@ from .base import api @api.get("/v1/instance") -@api.get("/v1/instance/") def instance_info(request): return { "uri": request.headers.get("host", settings.SETUP.MAIN_DOMAIN), diff --git a/api/views/oauth.py b/api/views/oauth.py index 6be2778..b97ce5a 100644 --- a/api/views/oauth.py +++ b/api/views/oauth.py @@ -66,7 +66,6 @@ class AuthorizationView(LoginRequiredMixin, TemplateView): class TokenView(View): def post(self, request): grant_type = request.POST["grant_type"] - scopes = set(self.request.POST.get("scope", "read").split()) try: application = Application.objects.get( client_id=self.request.POST["client_id"] @@ -84,9 +83,6 @@ class TokenView(View): token = Token.objects.get(code=code, application=application) except Token.DoesNotExist: return JsonResponse({"error": "invalid_code"}, status=400) - # Verify the scopes match the token - if scopes != set(token.scopes): - return JsonResponse({"error": "invalid_scope"}, status=400) # Update the token to remove its code token.code = None token.save() diff --git a/api/views/timelines.py b/api/views/timelines.py new file mode 100644 index 0000000..5de0e0f --- /dev/null +++ b/api/views/timelines.py @@ -0,0 +1,23 @@ +from activities.models import TimelineEvent + +from .. import schemas +from ..decorators import identity_required +from .base import api + + +@api.get("/v1/timelines/home", response=list[schemas.Status]) +@identity_required +def home(request): + if request.GET.get("max_id"): + return [] + limit = int(request.GET.get("limit", "20")) + events = ( + TimelineEvent.objects.filter( + identity=request.identity, + type__in=[TimelineEvent.Types.post], + ) + .select_related("subject_post", "subject_post__author") + .prefetch_related("subject_post__attachments") + .order_by("-created")[:limit] + ) + return [event.subject_post.to_mastodon_json() for event in events] diff --git a/takahe/settings.py b/takahe/settings.py index e2e9b43..a65367a 100644 --- a/takahe/settings.py +++ b/takahe/settings.py @@ -192,6 +192,7 @@ MIDDLEWARE = [ "django_htmx.middleware.HtmxMiddleware", "core.middleware.AcceptMiddleware", "core.middleware.ConfigLoadingMiddleware", + "api.middleware.ApiTokenMiddleware", "users.middleware.IdentityMiddleware", ] diff --git a/tests/api/test_accounts.py b/tests/api/test_accounts.py new file mode 100644 index 0000000..6ca37ae --- /dev/null +++ b/tests/api/test_accounts.py @@ -0,0 +1,12 @@ +import pytest + + +@pytest.mark.django_db +def test_verify_credentials(api_token, identity, client): + response = client.get( + "/api/v1/accounts/verify_credentials", + HTTP_AUTHORIZATION=f"Bearer {api_token.token}", + HTTP_ACCEPT="application/json", + ).json() + assert response["id"] == str(identity.pk) + assert response["username"] == identity.username diff --git a/tests/api/test_instance.py b/tests/api/test_instance.py new file mode 100644 index 0000000..9fd0af2 --- /dev/null +++ b/tests/api/test_instance.py @@ -0,0 +1,11 @@ +import pytest + + +@pytest.mark.django_db +def test_instance(api_token, client): + response = client.get( + "/api/v1/instance", + HTTP_AUTHORIZATION=f"Bearer {api_token.token}", + HTTP_ACCEPT="application/json", + ).json() + assert response["uri"] == "example.com" diff --git a/tests/conftest.py b/tests/conftest.py index 283de76..f2b9d64 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ import time import pytest +from api.models import Application, Token from core.models import Config from stator.runner import StatorModel, StatorRunner from users.models import Domain, Identity, User @@ -171,6 +172,26 @@ def remote_identity2() -> Identity: ) +@pytest.fixture +@pytest.mark.django_db +def api_token(identity) -> Token: + """ + Creates an API application, an identity, and a token for that identity + """ + application = Application.objects.create( + name="Test App", + client_id="tk-test", + client_secret="mytestappsecret", + ) + return Token.objects.create( + application=application, + user=identity.users.first(), + identity=identity, + token="mytestapitoken", + scopes=["read", "write", "follow", "push"], + ) + + @pytest.fixture def stator(config_system) -> StatorRunner: """ diff --git a/users/middleware.py b/users/middleware.py index e6d4036..9e7f50d 100644 --- a/users/middleware.py +++ b/users/middleware.py @@ -13,15 +13,21 @@ class IdentityMiddleware: self.get_response = get_response def __call__(self, request): - identity_id = request.session.get("identity_id") - if not identity_id: - request.identity = None - else: - try: - request.identity = Identity.objects.get(id=identity_id) - User.objects.filter(pk=request.user.pk).update(last_seen=timezone.now()) - except Identity.DoesNotExist: + # The API middleware might have set identity already + if not hasattr(request, "identity"): + # See if we have one in the session + identity_id = request.session.get("identity_id") + if not identity_id: request.identity = None + else: + # Pull it out of the DB and assign it + try: + request.identity = Identity.objects.get(id=identity_id) + User.objects.filter(pk=request.user.pk).update( + last_seen=timezone.now() + ) + except Identity.DoesNotExist: + request.identity = None response = self.get_response(request) return response diff --git a/users/models/identity.py b/users/models/identity.py index fe85d41..a8937c9 100644 --- a/users/models/identity.py +++ b/users/models/identity.py @@ -5,6 +5,7 @@ from urllib.parse import urlparse import httpx import urlman from asgiref.sync import async_to_sync, sync_to_async +from django.conf import settings from django.db import IntegrityError, models from django.template.defaultfilters import linebreaks_filter from django.templatetags.static import static @@ -13,7 +14,7 @@ from django.utils.functional import lazy from core.exceptions import ActorMismatchError from core.html import sanitize_post, strip_html -from core.ld import canonicalise, get_list, media_type_from_filename +from core.ld import canonicalise, format_ld_date, get_list, media_type_from_filename from core.models import Config from core.signatures import HttpSignature, RsaKeys from core.uploads import upload_namer @@ -153,7 +154,7 @@ class Identity(StatorModel): if self.icon: return self.icon.url elif self.icon_uri: - return f"/proxy/identity_icon/{self.pk}/" + return f"https://{settings.MAIN_DOMAIN}/proxy/identity_icon/{self.pk}/" else: return static("img/unknown-icon-128.png") @@ -164,7 +165,7 @@ class Identity(StatorModel): if self.image: return self.image.url elif self.image_uri: - return f"/proxy/identity_image/{self.pk}/" + return f"https://{settings.MAIN_DOMAIN}/proxy/identity_image/{self.pk}/" @property def safe_summary(self): @@ -466,6 +467,44 @@ class Identity(StatorModel): await sync_to_async(self.save)() return True + ### Mastodon Client API ### + + def to_mastodon_json(self): + return { + "id": self.pk, + "username": self.username, + "acct": self.username if self.local else self.handle, + "url": self.absolute_profile_uri(), + "display_name": self.name, + "note": self.summary or "", + "avatar": self.local_icon_url(), + "avatar_static": self.local_icon_url(), + "header": self.local_image_url() or "", + "header_static": self.local_image_url() or "", + "locked": False, + "fields": ( + [ + {"name": m["name"], "value": m["value"], "verified_at": None} + for m in self.metadata + ] + if self.metadata + else [] + ), + "emojis": [], + "bot": False, + "group": False, + "discoverable": self.discoverable, + "suspended": False, + "limited": False, + "created_at": format_ld_date( + self.created.replace(hour=0, minute=0, second=0, microsecond=0) + ), + "last_status_at": None, # TODO: populate + "statuses_count": self.posts.count(), + "followers_count": self.inbound_follows.count(), + "following_count": self.outbound_follows.count(), + } + ### Cryptography ### async def signed_request(