Start on push notification work

pull/611/head
Andrew Godwin 2023-07-15 12:37:34 -06:00
rodzic 824f5b289c
commit 11e3ca12d4
10 zmienionych plików z 265 dodań i 3 usunięć

Wyświetl plik

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

Wyświetl plik

@ -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()

Wyświetl plik

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

Wyświetl plik

@ -15,6 +15,7 @@ from api.views import (
notifications,
polls,
preferences,
push,
search,
statuses,
tags,
@ -46,6 +47,7 @@ urlpatterns = [
path("v1/announcements/<pk>/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/<id>/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),

Wyświetl plik

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

Wyświetl plik

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

70
api/views/push.py 100644
Wyświetl plik

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

Wyświetl plik

@ -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.

Wyświetl plik

@ -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
~~~~~~~~~~

Wyświetl plik

@ -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"