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), canonicalise(response.json(), include_security=True),
update=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 functools import partial
from django.conf import settings
from django.db import models from django.db import models
from core.uploads import upload_namer from core.uploads import upload_namer
@ -77,13 +78,13 @@ class PostAttachment(StatorModel):
elif self.file: elif self.file:
return self.file.url return self.file.url
else: else:
return f"/proxy/post_attachment/{self.pk}/" return f"https://{settings.MAIN_DOMAIN}/proxy/post_attachment/{self.pk}/"
def full_url(self): def full_url(self):
if self.file: if self.file:
return self.file.url return self.file.url
else: else:
return f"/proxy/post_attachment/{self.pk}/" return f"https://{settings.MAIN_DOMAIN}/proxy/post_attachment/{self.pk}/"
### ActivityPub ### ### ActivityPub ###
@ -97,3 +98,28 @@ class PostAttachment(StatorModel):
"mediaType": self.mimetype, "mediaType": self.mimetype,
"http://joinmastodon.org/ns#focalPoint": [0, 0], "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 .apps import * # noqa
from .base import api # noqa from .base import api # noqa
from .instance import * # 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 import secrets
from ninja import Field, Schema from ninja import Schema
from .. import schemas
from ..models import Application from ..models import Application
from .base import api from .base import api
@ -13,16 +14,7 @@ class CreateApplicationSchema(Schema):
website: None | str = None website: None | str = None
class ApplicationSchema(Schema): @api.post("/v1/apps", response=schemas.Application)
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)
def add_app(request, details: CreateApplicationSchema): def add_app(request, details: CreateApplicationSchema):
client_id = "tk-" + secrets.token_urlsafe(16) client_id = "tk-" + secrets.token_urlsafe(16)
client_secret = secrets.token_urlsafe(40) client_secret = secrets.token_urlsafe(40)

View file

@ -9,7 +9,6 @@ from .base import api
@api.get("/v1/instance") @api.get("/v1/instance")
@api.get("/v1/instance/")
def instance_info(request): def instance_info(request):
return { return {
"uri": request.headers.get("host", settings.SETUP.MAIN_DOMAIN), "uri": request.headers.get("host", settings.SETUP.MAIN_DOMAIN),

View file

@ -66,7 +66,6 @@ class AuthorizationView(LoginRequiredMixin, TemplateView):
class TokenView(View): class TokenView(View):
def post(self, request): def post(self, request):
grant_type = request.POST["grant_type"] grant_type = request.POST["grant_type"]
scopes = set(self.request.POST.get("scope", "read").split())
try: try:
application = Application.objects.get( application = Application.objects.get(
client_id=self.request.POST["client_id"] client_id=self.request.POST["client_id"]
@ -84,9 +83,6 @@ class TokenView(View):
token = Token.objects.get(code=code, application=application) token = Token.objects.get(code=code, application=application)
except Token.DoesNotExist: except Token.DoesNotExist:
return JsonResponse({"error": "invalid_code"}, status=400) 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 # Update the token to remove its code
token.code = None token.code = None
token.save() 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", "django_htmx.middleware.HtmxMiddleware",
"core.middleware.AcceptMiddleware", "core.middleware.AcceptMiddleware",
"core.middleware.ConfigLoadingMiddleware", "core.middleware.ConfigLoadingMiddleware",
"api.middleware.ApiTokenMiddleware",
"users.middleware.IdentityMiddleware", "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 import pytest
from api.models import Application, Token
from core.models import Config from core.models import Config
from stator.runner import StatorModel, StatorRunner from stator.runner import StatorModel, StatorRunner
from users.models import Domain, Identity, User 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 @pytest.fixture
def stator(config_system) -> StatorRunner: def stator(config_system) -> StatorRunner:
""" """

View file

@ -13,15 +13,21 @@ class IdentityMiddleware:
self.get_response = get_response self.get_response = get_response
def __call__(self, request): def __call__(self, request):
identity_id = request.session.get("identity_id") # The API middleware might have set identity already
if not identity_id: if not hasattr(request, "identity"):
request.identity = None # See if we have one in the session
else: identity_id = request.session.get("identity_id")
try: if not identity_id:
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 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) response = self.get_response(request)
return response return response

View file

@ -5,6 +5,7 @@ from urllib.parse import urlparse
import httpx import httpx
import urlman import urlman
from asgiref.sync import async_to_sync, sync_to_async from asgiref.sync import async_to_sync, sync_to_async
from django.conf import settings
from django.db import IntegrityError, models from django.db import IntegrityError, models
from django.template.defaultfilters import linebreaks_filter from django.template.defaultfilters import linebreaks_filter
from django.templatetags.static import static from django.templatetags.static import static
@ -13,7 +14,7 @@ from django.utils.functional import lazy
from core.exceptions import ActorMismatchError from core.exceptions import ActorMismatchError
from core.html import sanitize_post, strip_html 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.models import Config
from core.signatures import HttpSignature, RsaKeys from core.signatures import HttpSignature, RsaKeys
from core.uploads import upload_namer from core.uploads import upload_namer
@ -153,7 +154,7 @@ class Identity(StatorModel):
if self.icon: if self.icon:
return self.icon.url return self.icon.url
elif self.icon_uri: elif self.icon_uri:
return f"/proxy/identity_icon/{self.pk}/" return f"https://{settings.MAIN_DOMAIN}/proxy/identity_icon/{self.pk}/"
else: else:
return static("img/unknown-icon-128.png") return static("img/unknown-icon-128.png")
@ -164,7 +165,7 @@ class Identity(StatorModel):
if self.image: if self.image:
return self.image.url return self.image.url
elif self.image_uri: elif self.image_uri:
return f"/proxy/identity_image/{self.pk}/" return f"https://{settings.MAIN_DOMAIN}/proxy/identity_image/{self.pk}/"
@property @property
def safe_summary(self): def safe_summary(self):
@ -466,6 +467,44 @@ class Identity(StatorModel):
await sync_to_async(self.save)() await sync_to_async(self.save)()
return True 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 ### ### Cryptography ###
async def signed_request( async def signed_request(