From 11e3ca12d42e6ed5bf3818c1602ae4518e2b1104 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Sat, 15 Jul 2023 12:37:34 -0600 Subject: [PATCH] Start on push notification work --- .../0003_token_push_subscription.py | 18 +++++ api/models/token.py | 24 +++++++ api/schemas.py | 69 ++++++++++++++++++ api/urls.py | 13 ++++ api/views/apps.py | 15 +++- api/views/instance.py | 37 ++++++++++ api/views/push.py | 70 +++++++++++++++++++ docs/installation.rst | 5 ++ docs/releases/0.10.rst | 12 ++++ takahe/settings.py | 5 ++ 10 files changed, 265 insertions(+), 3 deletions(-) create mode 100644 api/migrations/0003_token_push_subscription.py create mode 100644 api/views/push.py diff --git a/api/migrations/0003_token_push_subscription.py b/api/migrations/0003_token_push_subscription.py new file mode 100644 index 0000000..3bae6ec --- /dev/null +++ b/api/migrations/0003_token_push_subscription.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.1 on 2023-07-15 17:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0002_remove_token_code_token_revoked_alter_token_token_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="token", + name="push_subscription", + field=models.JSONField(blank=True, null=True), + ), + ] diff --git a/api/models/token.py b/api/models/token.py index fc1f2ff..7c0c891 100644 --- a/api/models/token.py +++ b/api/models/token.py @@ -1,5 +1,22 @@ import urlman from django.db import models +from pydantic import BaseModel + + +class PushSubscriptionSchema(BaseModel): + """ + Basic validating schema for push data + """ + + class Keys(BaseModel): + p256dh: str + auth: str + + endpoint: str + keys: Keys + alerts: dict[str, bool] + policy: str + server_key: str class Token(models.Model): @@ -38,6 +55,8 @@ class Token(models.Model): updated = models.DateTimeField(auto_now=True) revoked = models.DateTimeField(blank=True, null=True) + push_subscription = models.JSONField(blank=True, null=True) + class urls(urlman.Urls): edit = "/@{self.identity.handle}/settings/tokens/{self.id}/" @@ -49,3 +68,8 @@ class Token(models.Model): # TODO: Support granular scopes the other way? scope_prefix = scope.split(":")[0] return (scope in self.scopes) or (scope_prefix in self.scopes) + + def set_push_subscription(self, data: dict): + # Validate schema and assign + self.push_subscription = PushSubscriptionSchema(**data).dict() + self.save() diff --git a/api/schemas.py b/api/schemas.py index 268cdb7..e82ff34 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -1,8 +1,10 @@ from typing import Literal, Optional, Union +from django.conf import settings from hatchway import Field, Schema from activities import models as activities_models +from api import models as api_models from core.html import FediverseHtmlParser from users import models as users_models from users.services import IdentityService @@ -15,6 +17,23 @@ class Application(Schema): client_id: str client_secret: str redirect_uri: str = Field(alias="redirect_uris") + vapid_key: str | None + + @classmethod + def from_application(cls, application: api_models.Application) -> "Application": + instance = cls.from_orm(application) + instance.vapid_key = settings.SETUP.VAPID_PUBLIC_KEY + return instance + + @classmethod + def from_application_no_keys( + cls, application: api_models.Application + ) -> "Application": + instance = cls.from_orm(application) + instance.vapid_key = settings.SETUP.VAPID_PUBLIC_KEY + instance.client_id = "" + instance.client_secret = "" + return instance class CustomEmoji(Schema): @@ -434,3 +453,53 @@ class Preferences(Schema): "reading:expand:spoilers": identity.config_identity.expand_content_warnings, } ) + + +class PushSubscriptionKeys(Schema): + p256dh: str + auth: str + + +class PushSubscriptionCreation(Schema): + endpoint: str + keys: PushSubscriptionKeys + + +class PushDataAlerts(Schema): + mention: bool = False + status: bool = False + reblog: bool = False + follow: bool = False + follow_request: bool = False + favourite: bool = False + poll: bool = False + update: bool = False + admin_sign_up: bool = Field(False, alias="admin.sign_up") + admin_report: bool = Field(False, alias="admin.report") + + +class PushData(Schema): + alerts: PushDataAlerts + policy: Literal["all", "followed", "follower", "none"] = "all" + + +class PushSubscription(Schema): + id: str + endpoint: str + alerts: PushDataAlerts + policy: str + server_key: str + + @classmethod + def from_token( + cls, + token: api_models.Token, + ) -> Optional["PushSubscription"]: + value = token.push_subscription + if value: + value["id"] = "1" + value["server_key"] = settings.VAPID_PUBLIC_KEY + del value["keys"] + return value + else: + return None diff --git a/api/urls.py b/api/urls.py index b479208..fd32e53 100644 --- a/api/urls.py +++ b/api/urls.py @@ -15,6 +15,7 @@ from api.views import ( notifications, polls, preferences, + push, search, statuses, tags, @@ -46,6 +47,7 @@ urlpatterns = [ path("v1/announcements//dismiss", announcements.announcement_dismiss), # Apps path("v1/apps", apps.add_app), + path("v1/apps/verify_credentials", apps.verify_credentials), # Bookmarks path("v1/bookmarks", bookmarks.bookmarks), # Emoji @@ -57,6 +59,7 @@ urlpatterns = [ path("v1/follow_requests", follow_requests.follow_requests), # Instance path("v1/instance", instance.instance_info_v1), + path("v1/instance/activity", instance.activity), path("v1/instance/peers", instance.peers), path("v2/instance", instance.instance_info_v2), # Lists @@ -84,6 +87,16 @@ urlpatterns = [ path("v1/polls//votes", polls.vote_poll), # Preferences path("v1/preferences", preferences.preferences), + # Push + path( + "v1/push/subscription", + methods( + get=push.get_subscription, + post=push.create_subscription, + put=push.update_subscription, + delete=push.delete_subscription, + ), + ), # Search path("v1/search", search.search), path("v2/search", search.search), diff --git a/api/views/apps.py b/api/views/apps.py index e20038d..3605c3e 100644 --- a/api/views/apps.py +++ b/api/views/apps.py @@ -1,7 +1,8 @@ from hatchway import QueryOrBody, api_view -from .. import schemas -from ..models import Application +from api import schemas +from api.decorators import scope_required +from api.models import Application @api_view.post @@ -18,4 +19,12 @@ def add_app( redirect_uris=redirect_uris, scopes=scopes, ) - return schemas.Application.from_orm(application) + return schemas.Application.from_application(application) + + +@scope_required("read") +@api_view.get +def verify_credentials( + request, +) -> schemas.Application: + return schemas.Application.from_application_no_keys(request.token.application) diff --git a/api/views/instance.py b/api/views/instance.py index 58d7455..87e9c96 100644 --- a/api/views/instance.py +++ b/api/views/instance.py @@ -1,5 +1,8 @@ +import datetime + from django.conf import settings from django.core.cache import cache +from django.utils import timezone from hatchway import api_view from activities.models import Post @@ -145,3 +148,37 @@ def peers(request) -> list[str]: "domain", flat=True ) ) + + +@api_view.get +def activity(request) -> list: + """ + Weekly activity endpoint + """ + # The stats are expensive to calculate, so don't do it very often + stats = cache.get("instance_activity_stats") + if stats is None: + stats = [] + # Work out our most recent week start + now = timezone.now() + week_start = now.replace( + hour=0, minute=0, second=0, microsecond=0 + ) - datetime.timedelta(now.weekday()) + for i in range(12): + week_end = week_start + datetime.timedelta(days=7) + stats.append( + { + "week": int(week_start.timestamp()), + "statuses": Post.objects.filter( + local=True, created__gte=week_start, created__lt=week_end + ).count(), + # TODO: Populate when we have identity activity tracking + "logins": 0, + "registrations": Identity.objects.filter( + local=True, created__gte=week_start, created__lt=week_end + ).count(), + } + ) + week_start -= datetime.timedelta(days=7) + cache.set("instance_activity_stats", stats, timeout=300) + return stats diff --git a/api/views/push.py b/api/views/push.py new file mode 100644 index 0000000..f55bee3 --- /dev/null +++ b/api/views/push.py @@ -0,0 +1,70 @@ +from django.conf import settings +from django.http import Http404 +from hatchway import ApiError, QueryOrBody, api_view + +from api import schemas +from api.decorators import scope_required + + +@scope_required("push") +@api_view.post +def create_subscription( + request, + subscription: QueryOrBody[schemas.PushSubscriptionCreation], + data: QueryOrBody[schemas.PushData], +) -> schemas.PushSubscription: + # First, check the server is set up to do push notifications + if not settings.SETUP.VAPID_PRIVATE_KEY: + raise Http404("Push not available") + # Then, register this with our token + request.token.set_push_subscription( + { + "endpoint": subscription.endpoint, + "keys": subscription.keys, + "alerts": data.alerts, + "policy": data.policy, + } + ) + # Then return the subscription + return schemas.PushSubscription.from_token(request.token) # type:ignore + + +@scope_required("push") +@api_view.get +def get_subscription(request) -> schemas.PushSubscription: + # First, check the server is set up to do push notifications + if not settings.SETUP.VAPID_PRIVATE_KEY: + raise Http404("Push not available") + # Get the subscription if it exists + subscription = schemas.PushSubscription.from_token(request.token) + if not subscription: + raise ApiError(404, "Not Found") + return subscription + + +@scope_required("push") +@api_view.put +def update_subscription( + request, data: QueryOrBody[schemas.PushData] +) -> schemas.PushSubscription: + # First, check the server is set up to do push notifications + if not settings.SETUP.VAPID_PRIVATE_KEY: + raise Http404("Push not available") + # Get the subscription if it exists + subscription = schemas.PushSubscription.from_token(request.token) + if not subscription: + raise ApiError(404, "Not Found") + # Update the subscription + subscription.alerts = data.alerts + subscription.policy = data.policy + request.token.set_push_subscription(subscription) + # Then return the subscription + return schemas.PushSubscription.from_token(request.token) # type:ignore + + +@scope_required("push") +@api_view.delete +def delete_subscription(request) -> dict: + # Unset the subscription + request.token.push_subscription = None + return {} diff --git a/docs/installation.rst b/docs/installation.rst index f8e48e8..368135c 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -119,6 +119,11 @@ be provided to the containers from the first boot. ``["andrew@aeracode.org"]`` (if you're doing this via shell, be careful about escaping!) +* If you want to support push notifications, set ``TAKAHE_VAPID_PUBLIC_KEY`` + and ``TAKAHE_VAPID_PRIVATE_KEY`` to a valid VAPID keypair (note that if you + ever change these, push notifications will stop working). You can generate + a keypair at `https://web-push-codelab.glitch.me/`_. + There are some other, optional variables you can tweak once the system is up and working - see :doc:`tuning` for more. diff --git a/docs/releases/0.10.rst b/docs/releases/0.10.rst index d33ccdb..4580002 100644 --- a/docs/releases/0.10.rst +++ b/docs/releases/0.10.rst @@ -25,6 +25,18 @@ or use the image name ``jointakahe/takahe:0.10``. Upgrade Notes ------------- +VAPID keys and Push notifications +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Takahē now supports push notifications if you supply a valid VAPID keypair as +the ``TAKAHE_VAPID_PUBLIC_KEY`` and ``TAKAHE_VAPID_PRIVATE_KEY`` environment +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. + + Migrations ~~~~~~~~~~ diff --git a/takahe/settings.py b/takahe/settings.py index 28e512b..9088d49 100644 --- a/takahe/settings.py +++ b/takahe/settings.py @@ -146,6 +146,11 @@ class Settings(BaseSettings): STATOR_CONCURRENCY: int = 50 STATOR_CONCURRENCY_PER_MODEL: int = 15 + # Web Push keys + # Generate via https://web-push-codelab.glitch.me/ + VAPID_PUBLIC_KEY: str | None = None + VAPID_PRIVATE_KEY: str | None = None + PGHOST: str | None = None PGPORT: int | None = 5432 PGNAME: str = "takahe"