Dan Watson 2024-04-20 03:20:50 +00:00 zatwierdzone przez GitHub
commit 9e2a09ef41
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
20 zmienionych plików z 482 dodań i 31 usunięć

1
.gitignore vendored
Wyświetl plik

@ -5,6 +5,7 @@
*.sqlite3
.DS_Store
.idea/*
.nova
.venv
.vscode
/*.env*

Wyświetl plik

@ -1,5 +1,7 @@
import logging
from django.db.models import OuterRef
from activities.models import (
Post,
PostInteraction,
@ -18,11 +20,11 @@ class PostService:
"""
@classmethod
def queryset(cls):
def queryset(cls, include_reply_to_author=False):
"""
Returns the base queryset to use for fetching posts efficiently.
"""
return (
qs = (
Post.objects.not_hidden()
.prefetch_related(
"attachments",
@ -34,6 +36,13 @@ class PostService:
"author__domain",
)
)
if include_reply_to_author:
qs = qs.annotate(
in_reply_to_author_id=Post.objects.filter(
object_uri=OuterRef("in_reply_to")
).values("author_id")[:1]
)
return qs
def __init__(self, post: Post):
self.post = post

Wyświetl plik

@ -8,7 +8,8 @@ from activities.models import (
TimelineEvent,
)
from activities.services import PostService
from users.models import Identity
from users.models import Identity, List
from users.services import IdentityService
class TimelineService:
@ -152,3 +153,30 @@ class TimelineService:
.filter(bookmarks__identity=self.identity)
.order_by("-id")
)
def for_list(self, alist: List) -> models.QuerySet[Post]:
"""
Return posts from members of `alist`, filtered by the lists replies policy.
"""
assert self.identity # Appease mypy
# We only need to include this if we need to filter on it.
include_author = alist.replies_policy == "followed"
members = alist.members.all()
queryset = PostService.queryset(include_reply_to_author=include_author)
match alist.replies_policy:
case "list":
# The default is to show posts (and replies) from list members.
criteria = models.Q(author__in=members)
case "none":
# Don't show any replies, just original posts from list members.
criteria = models.Q(author__in=members) & models.Q(
in_reply_to__isnull=True
)
case "followed":
# Show posts from list members OR from accounts you follow replying to
# posts by list members.
criteria = models.Q(author__in=members) | (
models.Q(author__in=IdentityService(self.identity).following())
& models.Q(in_reply_to_author_id__in=members)
)
return queryset.filter(criteria).order_by("-id")

Wyświetl plik

@ -407,11 +407,15 @@ class Announcement(Schema):
class List(Schema):
id: str
title: str
replies_policy: Literal[
"followed",
"list",
"none",
]
replies_policy: Literal["followed", "list", "none"]
exclusive: bool
@classmethod
def from_list(
cls,
list_instance: users_models.List,
) -> "List":
return cls(**list_instance.to_mastodon_json())
class Preferences(Schema):
@ -503,3 +507,16 @@ class PushSubscription(Schema):
return value
else:
return None
class Marker(Schema):
last_read_id: str
version: int
updated_at: str
@classmethod
def from_marker(
cls,
marker: users_models.Marker,
) -> "Marker":
return cls(**marker.to_mastodon_json())

Wyświetl plik

@ -11,6 +11,7 @@ from api.views import (
follow_requests,
instance,
lists,
markers,
media,
notifications,
polls,
@ -43,6 +44,7 @@ urlpatterns = [
path("v1/accounts/<id>/following", accounts.account_following),
path("v1/accounts/<id>/followers", accounts.account_followers),
path("v1/accounts/<id>/featured_tags", accounts.account_featured_tags),
path("v1/accounts/<id>/lists", accounts.account_lists),
# Announcements
path("v1/announcements", announcements.announcement_list),
path("v1/announcements/<pk>/dismiss", announcements.announcement_dismiss),
@ -66,7 +68,37 @@ urlpatterns = [
path("v1/instance/peers", instance.peers),
path("v2/instance", instance.instance_info_v2),
# Lists
path("v1/lists", lists.get_lists),
path(
"v1/lists",
methods(
get=lists.get_lists,
post=lists.create_list,
),
),
path(
"v1/lists/<id>",
methods(
get=lists.get_list,
put=lists.update_list,
delete=lists.delete_list,
),
),
path(
"v1/lists/<id>/accounts",
methods(
get=lists.get_accounts,
post=lists.add_accounts,
delete=lists.delete_accounts,
),
),
# Markers
path(
"v1/markers",
methods(
get=markers.markers,
post=markers.set_markers,
),
),
# Media
path("v1/media", media.upload_media),
path("v2/media", media.upload_media),
@ -125,6 +157,7 @@ urlpatterns = [
path("v1/timelines/home", timelines.home),
path("v1/timelines/public", timelines.public),
path("v1/timelines/tag/<hashtag>", timelines.hashtag),
path("v1/timelines/list/<list_id>", timelines.list_timeline),
path("v1/conversations", timelines.conversations),
path("v1/favourites", timelines.favourites),
# Trends

Wyświetl plik

@ -373,3 +373,15 @@ def account_followers(
def account_featured_tags(request: HttpRequest, id: str) -> list[schemas.FeaturedTag]:
# Not implemented yet
return []
@scope_required("read:lists")
@api_view.get
def account_lists(request: HttpRequest, id: str) -> list[schemas.List]:
identity = get_object_or_404(
Identity.objects.exclude(restriction=Identity.Restriction.blocked), pk=id
)
return [
schemas.List.from_list(lst)
for lst in request.identity.lists.filter(members=identity)
]

Wyświetl plik

@ -1,12 +1,95 @@
from typing import Literal
from django.http import HttpRequest
from hatchway import api_view
from django.shortcuts import get_object_or_404
from hatchway import Schema, api_view
from api import schemas
from api.decorators import scope_required
class CreateList(Schema):
title: str
replies_policy: Literal["followed", "list", "none"] = "list"
exclusive: bool = False
class UpdateList(Schema):
title: str | None
replies_policy: Literal["followed", "list", "none"] | None
exclusive: bool | None
@scope_required("read:lists")
@api_view.get
def get_lists(request: HttpRequest) -> list[schemas.List]:
# We don't implement this yet
return []
return [schemas.List.from_list(lst) for lst in request.identity.lists.all()]
@scope_required("write:lists")
@api_view.post
def create_list(request: HttpRequest, data: CreateList) -> schemas.List:
created = request.identity.lists.create(
title=data.title,
replies_policy=data.replies_policy,
exclusive=data.exclusive,
)
return schemas.List.from_list(created)
@scope_required("read:lists")
@api_view.get
def get_list(request: HttpRequest, id: str) -> schemas.List:
alist = get_object_or_404(request.identity.lists, pk=id)
return schemas.List.from_list(alist)
@scope_required("write:lists")
@api_view.put
def update_list(request: HttpRequest, id: str, data: UpdateList) -> schemas.List:
alist = get_object_or_404(request.identity.lists, pk=id)
if data.title:
alist.title = data.title
if data.replies_policy:
alist.replies_policy = data.replies_policy
if data.exclusive is not None:
alist.exclusive = data.exclusive
alist.save()
return schemas.List.from_list(alist)
@scope_required("write:lists")
@api_view.delete
def delete_list(request: HttpRequest, id: str) -> dict:
alist = get_object_or_404(request.identity.lists, pk=id)
alist.delete()
return {}
@scope_required("write:lists")
@api_view.get
def get_accounts(request: HttpRequest, id: str) -> list[schemas.Account]:
alist = get_object_or_404(request.identity.lists, pk=id)
return [schemas.Account.from_identity(ident) for ident in alist.members.all()]
@scope_required("write:lists")
@api_view.post
def add_accounts(request: HttpRequest, id: str) -> dict:
alist = get_object_or_404(request.identity.lists, pk=id)
add_ids = request.PARAMS.get("account_ids")
for follow in request.identity.outbound_follows.filter(
target__id__in=add_ids
).select_related("target"):
alist.members.add(follow.target)
return {}
@scope_required("write:lists")
@api_view.delete
def delete_accounts(request: HttpRequest, id: str) -> dict:
alist = get_object_or_404(request.identity.lists, pk=id)
remove_ids = request.PARAMS.get("account_ids")
for ident in alist.members.filter(id__in=remove_ids):
alist.members.remove(ident)
return {}

Wyświetl plik

@ -0,0 +1,36 @@
from django.http import HttpRequest
from hatchway import api_view
from api import schemas
from api.decorators import scope_required
@scope_required("read:statuses")
@api_view.get
def markers(request: HttpRequest) -> dict[str, schemas.Marker]:
timelines = set(request.PARAMS.getlist("timeline[]"))
data = {}
for m in request.identity.markers.filter(timeline__in=timelines):
data[m.timeline] = schemas.Marker.from_marker(m)
return data
@scope_required("write:statuses")
@api_view.post
def set_markers(request: HttpRequest) -> dict[str, schemas.Marker]:
markers = {}
for key, last_id in request.PARAMS.items():
if not key.endswith("[last_read_id]"):
continue
timeline = key.replace("[last_read_id]", "")
marker, created = request.identity.markers.get_or_create(
timeline=timeline,
defaults={
"last_read_id": last_id,
},
)
if not created:
marker.last_read_id = last_id
marker.save()
markers[timeline] = schemas.Marker.from_marker(marker)
return markers

Wyświetl plik

@ -1,5 +1,4 @@
import base64
import json
import secrets
import time
from urllib.parse import urlparse, urlunparse
@ -41,19 +40,6 @@ class OauthRedirect(HttpResponseRedirect):
super().__init__(urlunparse(url_parts))
def get_json_and_formdata(request):
# Did they submit JSON?
if request.content_type == "application/json" and request.body.strip():
return json.loads(request.body)
# Fall back to form data
value = {}
for key, item in request.POST.items():
value[key] = item
for key, item in request.GET.items():
value[key] = item
return value
class AuthorizationView(LoginRequiredMixin, View):
"""
Asks the user to authorize access.
@ -106,7 +92,7 @@ class AuthorizationView(LoginRequiredMixin, View):
return render(request, "api/oauth_authorize.html", context)
def post(self, request):
post_data = get_json_and_formdata(request)
post_data = request.PARAMS
# Grab the application and other details again
redirect_uri = post_data["redirect_uri"]
scope = post_data["scope"]
@ -160,7 +146,7 @@ class TokenView(View):
)
def post(self, request):
post_data = get_json_and_formdata(request)
post_data = request.PARAMS.copy()
auth_client_id, auth_client_secret = extract_client_info_from_basic_auth(
request
)
@ -243,7 +229,7 @@ class TokenView(View):
@method_decorator(csrf_exempt, name="dispatch")
class RevokeTokenView(View):
def post(self, request):
post_data = get_json_and_formdata(request)
post_data = request.PARAMS.copy()
auth_client_id, auth_client_secret = extract_client_info_from_basic_auth(
request
)

Wyświetl plik

@ -1,4 +1,5 @@
from django.http import HttpRequest
from django.shortcuts import get_object_or_404
from hatchway import ApiError, ApiResponse, api_view
from activities.models import Post, TimelineEvent
@ -159,3 +160,31 @@ def favourites(
request=request,
include_params=["limit"],
)
@scope_required("read:lists")
@api_view.get
def list_timeline(
request: HttpRequest,
list_id: str,
max_id: str | None = None,
since_id: str | None = None,
min_id: str | None = None,
limit: int = 20,
) -> ApiResponse[list[schemas.Status]]:
alist = get_object_or_404(request.identity.lists, pk=list_id)
queryset = TimelineService(request.identity).for_list(alist)
paginator = MastodonPaginator()
pager: PaginationResult[Post] = paginator.paginate(
queryset,
min_id=min_id,
max_id=max_id,
since_id=since_id,
limit=limit,
)
return PaginatingApiResponse(
schemas.Status.map_from_post(pager.results, request.identity),
request=request,
include_params=["limit"],
)

Wyświetl plik

@ -1,3 +1,4 @@
import json
from time import time
from django.conf import settings
@ -73,3 +74,25 @@ def show_toolbar(request):
Determines whether to show the debug toolbar on a given page.
"""
return settings.DEBUG and request.user.is_authenticated and request.user.admin
class ParamsMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def make_params(self, request):
# See https://docs.joinmastodon.org/client/intro/#parameters
# If they sent JSON, use that.
if request.content_type == "application/json" and request.body.strip():
return json.loads(request.body)
# Otherwise, fall back to form data.
params = {}
for key, value in request.GET.items():
params[key] = value
for key, value in request.POST.items():
params[key] = value
return params
def __call__(self, request):
request.PARAMS = self.make_params(request)
return self.get_response(request)

Wyświetl plik

@ -9,6 +9,7 @@ Currently, it supports:
* A web UI (which can be installed as a PWA as well)
* Mastodon-compatible client applications (beta support)
* Posts with content warnings and visibilities including a local-only option
* Creating polls on posts
* Editing post content
* Viewing images, videos and other post attachments
* Uploading images and attaching image captions
@ -28,6 +29,8 @@ Currently, it supports:
* Server defederation (blocking)
* Signup flow, including auto-cap by user numbers and invite system
* Password reset via email
* Bookmarks
* Markers
Features planned for releases up to 1.0:
@ -41,9 +44,7 @@ Features planned for releases up to 1.0:
Features that may make it into 1.0, or might be further out:
* Creating polls on posts
* Filters
* Bookmarks
* Lists
* Scheduling posts
* Mastodon-compatible account migration target/source

Wyświetl plik

@ -13,3 +13,17 @@ variables. You can generate a keypair via `https://web-push-codelab.glitch.me/`_
Note that users of apps may need to sign out and in again to their accounts for
the app to notice that it can now do push notifications. Some apps, like Elk,
may cache the fact your server didn't support it for a while.
Marker Support
~~~~~~~~~~~~~~
Takahē now supports the `Markers API <https://docs.joinmastodon.org/methods/markers/>`_,
used by clients to sync read positions within timelines.
Lists Support
~~~~~~~~~~~~~
Takahē now supports the `Lists APIs <https://docs.joinmastodon.org/methods/lists/>`_,
used by clients to maintain lists of accounts to show timelines for.

Wyświetl plik

@ -233,6 +233,7 @@ MIDDLEWARE = [
"django_htmx.middleware.HtmxMiddleware",
"core.middleware.HeadersMiddleware",
"core.middleware.ConfigLoadingMiddleware",
"core.middleware.ParamsMiddleware",
"api.middleware.ApiTokenMiddleware",
"users.middleware.DomainMiddleware",
]

Wyświetl plik

@ -13,6 +13,8 @@ from users.models import (
Identity,
InboxMessage,
Invite,
List,
Marker,
PasswordReset,
Report,
User,
@ -212,6 +214,17 @@ class InviteAdmin(admin.ModelAdmin):
list_display = ["id", "created", "token", "note"]
@admin.register(List)
class ListAdmin(admin.ModelAdmin):
list_display = ["id", "identity", "title", "replies_policy", "exclusive"]
autocomplete_fields = ["members"]
@admin.register(Marker)
class MarkerAdmin(admin.ModelAdmin):
list_display = ["id", "identity", "timeline", "last_read_id", "updated_at"]
@admin.register(Report)
class ReportAdmin(admin.ModelAdmin):
list_display = ["id", "created", "resolved", "type", "subject_identity"]

Wyświetl plik

@ -0,0 +1,41 @@
# Generated by Django 4.2.11 on 2024-04-17 03:09
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("users", "0022_follow_request"),
]
operations = [
migrations.CreateModel(
name="Marker",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("timeline", models.CharField(max_length=100)),
("last_read_id", models.CharField(max_length=200)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"identity",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="markers",
to="users.identity",
),
),
],
options={
"unique_together": {("identity", "timeline")},
},
),
]

Wyświetl plik

@ -0,0 +1,54 @@
# Generated by Django 4.2.11 on 2024-04-19 01:03
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("users", "0023_marker"),
]
operations = [
migrations.CreateModel(
name="List",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("title", models.CharField(max_length=200)),
(
"replies_policy",
models.CharField(
choices=[
("followed", "Followed"),
("list", "List Only"),
("none", "None"),
],
max_length=10,
),
),
("exclusive", models.BooleanField()),
(
"identity",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="lists",
to="users.identity",
),
),
(
"members",
models.ManyToManyField(
blank=True, related_name="in_lists", to="users.identity"
),
),
],
),
]

Wyświetl plik

@ -7,6 +7,8 @@ from .hashtag_follow import HashtagFollow # noqa
from .identity import Identity, IdentityStates # noqa
from .inbox_message import InboxMessage, InboxMessageStates # noqa
from .invite import Invite # noqa
from .lists import List # noqa
from .marker import Marker # noqa
from .password_reset import PasswordReset # noqa
from .report import Report # noqa
from .system_actor import SystemActor # noqa

Wyświetl plik

@ -0,0 +1,37 @@
from django.db import models
class List(models.Model):
"""
A list of accounts.
"""
class RepliesPolicy(models.TextChoices):
followed = "followed"
list_only = "list"
none = "none"
identity = models.ForeignKey(
"users.Identity",
on_delete=models.CASCADE,
related_name="lists",
)
title = models.CharField(max_length=200)
replies_policy = models.CharField(max_length=10, choices=RepliesPolicy.choices)
exclusive = models.BooleanField()
members = models.ManyToManyField(
"users.Identity",
related_name="in_lists",
blank=True,
)
def __str__(self):
return f"#{self.id}: {self.identity}{self.title}"
def to_mastodon_json(self):
return {
"id": str(self.id),
"title": self.title,
"replies_policy": self.replies_policy,
"exclusive": self.exclusive,
}

Wyświetl plik

@ -0,0 +1,31 @@
from django.db import models
from core.ld import format_ld_date
class Marker(models.Model):
"""
A timeline marker.
"""
identity = models.ForeignKey(
"users.Identity",
on_delete=models.CASCADE,
related_name="markers",
)
timeline = models.CharField(max_length=100)
last_read_id = models.CharField(max_length=200)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = [("identity", "timeline")]
def __str__(self):
return f"#{self.id}: {self.identity}{self.timeline}[{self.last_read_id}]"
def to_mastodon_json(self):
return {
"last_read_id": self.last_read_id,
"version": 0,
"updated_at": format_ld_date(self.updated_at),
}