Start on push notification work

This commit is contained in:
Andrew Godwin 2023-07-15 12:37:34 -06:00
parent 824f5b289c
commit 11e3ca12d4
10 changed files with 265 additions and 3 deletions

View file

@ -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),
),
]

View file

@ -1,5 +1,22 @@
import urlman import urlman
from django.db import models 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): class Token(models.Model):
@ -38,6 +55,8 @@ class Token(models.Model):
updated = models.DateTimeField(auto_now=True) updated = models.DateTimeField(auto_now=True)
revoked = models.DateTimeField(blank=True, null=True) revoked = models.DateTimeField(blank=True, null=True)
push_subscription = models.JSONField(blank=True, null=True)
class urls(urlman.Urls): class urls(urlman.Urls):
edit = "/@{self.identity.handle}/settings/tokens/{self.id}/" edit = "/@{self.identity.handle}/settings/tokens/{self.id}/"
@ -49,3 +68,8 @@ class Token(models.Model):
# TODO: Support granular scopes the other way? # TODO: Support granular scopes the other way?
scope_prefix = scope.split(":")[0] scope_prefix = scope.split(":")[0]
return (scope in self.scopes) or (scope_prefix in self.scopes) 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()

View file

@ -1,8 +1,10 @@
from typing import Literal, Optional, Union from typing import Literal, Optional, Union
from django.conf import settings
from hatchway import Field, Schema from hatchway import Field, Schema
from activities import models as activities_models from activities import models as activities_models
from api import models as api_models
from core.html import FediverseHtmlParser from core.html import FediverseHtmlParser
from users import models as users_models from users import models as users_models
from users.services import IdentityService from users.services import IdentityService
@ -15,6 +17,23 @@ class Application(Schema):
client_id: str client_id: str
client_secret: str client_secret: str
redirect_uri: str = Field(alias="redirect_uris") 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): class CustomEmoji(Schema):
@ -434,3 +453,53 @@ class Preferences(Schema):
"reading:expand:spoilers": identity.config_identity.expand_content_warnings, "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

View file

@ -15,6 +15,7 @@ from api.views import (
notifications, notifications,
polls, polls,
preferences, preferences,
push,
search, search,
statuses, statuses,
tags, tags,
@ -46,6 +47,7 @@ urlpatterns = [
path("v1/announcements/<pk>/dismiss", announcements.announcement_dismiss), path("v1/announcements/<pk>/dismiss", announcements.announcement_dismiss),
# Apps # Apps
path("v1/apps", apps.add_app), path("v1/apps", apps.add_app),
path("v1/apps/verify_credentials", apps.verify_credentials),
# Bookmarks # Bookmarks
path("v1/bookmarks", bookmarks.bookmarks), path("v1/bookmarks", bookmarks.bookmarks),
# Emoji # Emoji
@ -57,6 +59,7 @@ urlpatterns = [
path("v1/follow_requests", follow_requests.follow_requests), path("v1/follow_requests", follow_requests.follow_requests),
# Instance # Instance
path("v1/instance", instance.instance_info_v1), path("v1/instance", instance.instance_info_v1),
path("v1/instance/activity", instance.activity),
path("v1/instance/peers", instance.peers), path("v1/instance/peers", instance.peers),
path("v2/instance", instance.instance_info_v2), path("v2/instance", instance.instance_info_v2),
# Lists # Lists
@ -84,6 +87,16 @@ urlpatterns = [
path("v1/polls/<id>/votes", polls.vote_poll), path("v1/polls/<id>/votes", polls.vote_poll),
# Preferences # Preferences
path("v1/preferences", preferences.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 # Search
path("v1/search", search.search), path("v1/search", search.search),
path("v2/search", search.search), path("v2/search", search.search),

View file

@ -1,7 +1,8 @@
from hatchway import QueryOrBody, api_view from hatchway import QueryOrBody, api_view
from .. import schemas from api import schemas
from ..models import Application from api.decorators import scope_required
from api.models import Application
@api_view.post @api_view.post
@ -18,4 +19,12 @@ def add_app(
redirect_uris=redirect_uris, redirect_uris=redirect_uris,
scopes=scopes, 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)

View file

@ -1,5 +1,8 @@
import datetime
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.utils import timezone
from hatchway import api_view from hatchway import api_view
from activities.models import Post from activities.models import Post
@ -145,3 +148,37 @@ def peers(request) -> list[str]:
"domain", flat=True "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

70
api/views/push.py Normal file
View file

@ -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 {}

View file

@ -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 ``["andrew@aeracode.org"]`` (if you're doing this via shell, be careful
about escaping!) 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 There are some other, optional variables you can tweak once the
system is up and working - see :doc:`tuning` for more. system is up and working - see :doc:`tuning` for more.

View file

@ -25,6 +25,18 @@ or use the image name ``jointakahe/takahe:0.10``.
Upgrade Notes 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 Migrations
~~~~~~~~~~ ~~~~~~~~~~

View file

@ -146,6 +146,11 @@ class Settings(BaseSettings):
STATOR_CONCURRENCY: int = 50 STATOR_CONCURRENCY: int = 50
STATOR_CONCURRENCY_PER_MODEL: int = 15 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 PGHOST: str | None = None
PGPORT: int | None = 5432 PGPORT: int | None = 5432
PGNAME: str = "takahe" PGNAME: str = "takahe"