Timelines working
This commit is contained in:
parent
1017c71ba1
commit
3e062aed36
@ -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,
|
||||
}
|
||||
|
@ -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
19
api/decorators.py
Normal 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
27
api/middleware.py
Normal 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
108
api/schemas/__init__.py
Normal 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
|
@ -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
9
api/views/accounts.py
Normal 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()
|
@ -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)
|
||||
|
@ -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),
|
||||
|
@ -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
23
api/views/timelines.py
Normal 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]
|
@ -192,6 +192,7 @@ MIDDLEWARE = [
|
||||
"django_htmx.middleware.HtmxMiddleware",
|
||||
"core.middleware.AcceptMiddleware",
|
||||
"core.middleware.ConfigLoadingMiddleware",
|
||||
"api.middleware.ApiTokenMiddleware",
|
||||
"users.middleware.IdentityMiddleware",
|
||||
]
|
||||
|
||||
|
12
tests/api/test_accounts.py
Normal file
12
tests/api/test_accounts.py
Normal 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
|
11
tests/api/test_instance.py
Normal file
11
tests/api/test_instance.py
Normal 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"
|
@ -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:
|
||||
"""
|
||||
|
@ -13,13 +13,19 @@ class IdentityMiddleware:
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
# 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())
|
||||
User.objects.filter(pk=request.user.pk).update(
|
||||
last_seen=timezone.now()
|
||||
)
|
||||
except Identity.DoesNotExist:
|
||||
request.identity = None
|
||||
|
||||
|
@ -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(
|
||||
|
Reference in New Issue
Block a user