Timelines working

This commit is contained in:
Andrew Godwin 2022-12-11 00:25:48 -07:00
parent 1017c71ba1
commit 3e062aed36
17 changed files with 368 additions and 29 deletions

View File

@ -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,
}

View File

@ -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,
}

19
api/decorators.py Normal file
View File

@ -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

27
api/middleware.py Normal file
View File

@ -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

108
api/schemas/__init__.py Normal file
View File

@ -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

View File

@ -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

9
api/views/accounts.py Normal file
View File

@ -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()

View File

@ -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)

View File

@ -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),

View File

@ -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()

23
api/views/timelines.py Normal file
View File

@ -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]

View File

@ -192,6 +192,7 @@ MIDDLEWARE = [
"django_htmx.middleware.HtmxMiddleware",
"core.middleware.AcceptMiddleware",
"core.middleware.ConfigLoadingMiddleware",
"api.middleware.ApiTokenMiddleware",
"users.middleware.IdentityMiddleware",
]

View File

@ -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

View File

@ -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"

View File

@ -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:
"""

View File

@ -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

View File

@ -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(