summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--activities/models/post.py47
-rw-r--r--activities/models/post_attachment.py30
-rw-r--r--api/decorators.py19
-rw-r--r--api/middleware.py27
-rw-r--r--api/schemas/__init__.py108
-rw-r--r--api/views/__init__.py3
-rw-r--r--api/views/accounts.py9
-rw-r--r--api/views/apps.py14
-rw-r--r--api/views/instance.py1
-rw-r--r--api/views/oauth.py4
-rw-r--r--api/views/timelines.py23
-rw-r--r--takahe/settings.py1
-rw-r--r--tests/api/test_accounts.py12
-rw-r--r--tests/api/test_instance.py11
-rw-r--r--tests/conftest.py21
-rw-r--r--users/middleware.py22
-rw-r--r--users/models/identity.py45
17 files changed, 368 insertions, 29 deletions
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
@@ -172,6 +173,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:
"""
Return an initialized StatorRunner for tests that need state transitioning
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(