mirror of
https://github.com/jointakahe/takahe.git
synced 2024-11-25 16:51:00 +00:00
Start on push notification work
This commit is contained in:
parent
824f5b289c
commit
11e3ca12d4
10 changed files with 265 additions and 3 deletions
18
api/migrations/0003_token_push_subscription.py
Normal file
18
api/migrations/0003_token_push_subscription.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
|
|
13
api/urls.py
13
api/urls.py
|
@ -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),
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
70
api/views/push.py
Normal 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 {}
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
~~~~~~~~~~
|
~~~~~~~~~~
|
||||||
|
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue