diff --git a/.gitignore b/.gitignore index 31926cc..0014bfa 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ *.sqlite3 .DS_Store .idea/* +.nova .venv .vscode /*.env* diff --git a/api/schemas.py b/api/schemas.py index 0f0441c..ca6d516 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -503,3 +503,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()) diff --git a/api/urls.py b/api/urls.py index 842aec8..65667a1 100644 --- a/api/urls.py +++ b/api/urls.py @@ -11,6 +11,7 @@ from api.views import ( follow_requests, instance, lists, + markers, media, notifications, polls, @@ -67,6 +68,14 @@ urlpatterns = [ path("v2/instance", instance.instance_info_v2), # Lists path("v1/lists", lists.get_lists), + # 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), diff --git a/api/views/markers.py b/api/views/markers.py new file mode 100644 index 0000000..1af22ef --- /dev/null +++ b/api/views/markers.py @@ -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 diff --git a/api/views/oauth.py b/api/views/oauth.py index a7d67d3..51fd893 100644 --- a/api/views/oauth.py +++ b/api/views/oauth.py @@ -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 ) diff --git a/core/middleware.py b/core/middleware.py index db60559..4121623 100644 --- a/core/middleware.py +++ b/core/middleware.py @@ -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) diff --git a/docs/features.rst b/docs/features.rst index 0cbaf69..b1fc995 100644 --- a/docs/features.rst +++ b/docs/features.rst @@ -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 diff --git a/docs/releases/next.rst b/docs/releases/next.rst index 207ff69..cae7bd4 100644 --- a/docs/releases/next.rst +++ b/docs/releases/next.rst @@ -13,3 +13,10 @@ 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 `_, +used by clients to sync read positions within timelines. diff --git a/takahe/settings.py b/takahe/settings.py index 2bd43b2..6fc9f9a 100644 --- a/takahe/settings.py +++ b/takahe/settings.py @@ -233,6 +233,7 @@ MIDDLEWARE = [ "django_htmx.middleware.HtmxMiddleware", "core.middleware.HeadersMiddleware", "core.middleware.ConfigLoadingMiddleware", + "core.middleware.ParamsMiddleware", "api.middleware.ApiTokenMiddleware", "users.middleware.DomainMiddleware", ] diff --git a/users/admin.py b/users/admin.py index 4d920f2..6ec0e27 100644 --- a/users/admin.py +++ b/users/admin.py @@ -13,6 +13,7 @@ from users.models import ( Identity, InboxMessage, Invite, + Marker, PasswordReset, Report, User, @@ -212,6 +213,11 @@ class InviteAdmin(admin.ModelAdmin): list_display = ["id", "created", "token", "note"] +@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"] diff --git a/users/migrations/0023_marker.py b/users/migrations/0023_marker.py new file mode 100644 index 0000000..32d8e1e --- /dev/null +++ b/users/migrations/0023_marker.py @@ -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")}, + }, + ), + ] diff --git a/users/models/__init__.py b/users/models/__init__.py index 8396e42..c0a91d0 100644 --- a/users/models/__init__.py +++ b/users/models/__init__.py @@ -7,6 +7,7 @@ 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 .marker import Marker # noqa from .password_reset import PasswordReset # noqa from .report import Report # noqa from .system_actor import SystemActor # noqa diff --git a/users/models/marker.py b/users/models/marker.py new file mode 100644 index 0000000..f18bedb --- /dev/null +++ b/users/models/marker.py @@ -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), + }