zerver: Add PushDevice model.

This commit adds a `PushDevice` model where each row
corresponds to an account on an install of the app
that has attempted to register with the bouncer to
receive mobile push notifications.

This is the core server table storing registrations
that are potentially registered with the mobile push
notifications bouncer service.
This commit is contained in:
Prakhar Pratyush
2025-06-02 15:42:53 +05:30
committed by Tim Abbott
parent 3c6a3b0d77
commit 6a4b06b6f4
5 changed files with 144 additions and 14 deletions

View File

@@ -176,6 +176,7 @@ ALL_ZULIP_TABLES = {
"zerver_preregistrationuser_streams",
"zerver_preregistrationuser_groups",
"zerver_presencesequence",
"zerver_pushdevice",
"zerver_pushdevicetoken",
"zerver_reaction",
"zerver_realm",
@@ -237,6 +238,7 @@ NON_EXPORTED_TABLES = {
"zerver_scheduledmessagenotificationemail",
# When switching servers, clients will need to re-log in and
# reregister for push notifications anyway.
"zerver_pushdevice",
"zerver_pushdevicetoken",
# We don't use these generated Django tables
"zerver_userprofile_groups",

View File

@@ -0,0 +1,58 @@
# Generated by Django 5.2.3 on 2025-07-13 03:14
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("zerver", "0729_externalauthid"),
]
operations = [
migrations.CreateModel(
name="PushDevice",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
(
"token_kind",
models.CharField(choices=[("apns", "APNs"), ("fcm", "FCM")], max_length=4),
),
("push_account_id", models.BigIntegerField()),
("push_public_key", models.TextField()),
("bouncer_device_id", models.BigIntegerField(null=True)),
(
"error_code",
models.CharField(
choices=[
("INVALID_BOUNCER_PUBLIC_KEY", "Invalid Bouncer Public Key"),
("BAD_REQUEST", "Invalid Encrypted Push Registration"),
("REQUEST_EXPIRED", "Request Expired"),
],
max_length=100,
null=True,
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
options={
"constraints": [
models.UniqueConstraint(
fields=("user", "push_account_id"),
name="unique_push_device_user_push_account_id",
)
],
},
),
]

View File

@@ -42,6 +42,7 @@ from zerver.models.prereg_users import PreregistrationUser as PreregistrationUse
from zerver.models.prereg_users import RealmReactivationStatus as RealmReactivationStatus
from zerver.models.presence import UserPresence as UserPresence
from zerver.models.presence import UserStatus as UserStatus
from zerver.models.push_notifications import AbstractPushDevice as AbstractPushDevice
from zerver.models.push_notifications import AbstractPushDeviceToken as AbstractPushDeviceToken
from zerver.models.push_notifications import PushDeviceToken as PushDeviceToken
from zerver.models.realm_audit_logs import AbstractRealmAuditLog as AbstractRealmAuditLog

View File

@@ -1,6 +1,13 @@
from django.db import models
from django.db.models import CASCADE
from typing import Literal
from django.db import models
from django.db.models import CASCADE, UniqueConstraint
from zerver.lib.exceptions import (
InvalidBouncerPublicKeyError,
InvalidEncryptedPushRegistrationError,
RequestExpiredError,
)
from zerver.models.users import UserProfile
@@ -40,3 +47,69 @@ class PushDeviceToken(AbstractPushDeviceToken):
class Meta:
unique_together = ("user", "kind", "token")
class AbstractPushDevice(models.Model):
class TokenKind(models.TextChoices):
APNS = "apns", "APNs"
FCM = "fcm", "FCM"
token_kind = models.CharField(max_length=4, choices=TokenKind.choices)
# 64-bit random integer ID generated by the client; will only be
# guaranteed to be unique within the client's own table of accounts.
push_account_id = models.BigIntegerField()
class Meta:
abstract = True
class PushDevice(AbstractPushDevice):
"""Core zulip server table storing registrations that are potentially
registered with the mobile push notifications bouncer service.
Each row corresponds to an account on an install of the app
that has attempted to register with the bouncer to receive
mobile push notifications.
"""
# The user on this server to whom this PushDevice belongs.
user = models.ForeignKey(UserProfile, on_delete=CASCADE)
# Key to use to encrypt notifications for delivery to this device.
push_public_key = models.TextField()
# `device_id` of the corresponding `RemotePushDevice`
# row created after successful registration to bouncer.
# Set to NULL while registration to bouncer is in progress or failed.
bouncer_device_id = models.BigIntegerField(null=True)
class ErrorCode(models.TextChoices):
INVALID_BOUNCER_PUBLIC_KEY = InvalidBouncerPublicKeyError.code.name
INVALID_ENCRYPTED_PUSH_REGISTRATION = InvalidEncryptedPushRegistrationError.code.name
REQUEST_EXPIRED = RequestExpiredError.code.name
# The error code returned when registration to bouncer fails.
# Set to NULL if the registration is in progress or successful.
error_code = models.CharField(max_length=100, choices=ErrorCode.choices, null=True)
class Meta:
constraints = [
UniqueConstraint(
# In theory, there's possibility that a user with
# two different devices having the same 'push_account_id'
# generated by the client to register. But the API treats
# that as idempotent request.
# We treat (user, push_account_id) as a unique registration.
fields=["user", "push_account_id"],
name="unique_push_device_user_push_account_id",
),
]
@property
def status(self) -> Literal["active", "pending", "failed"]:
if self.error_code is not None:
return "failed"
elif self.bouncer_device_id is None:
return "pending"
return "active"

View File

@@ -11,7 +11,13 @@ from typing_extensions import override
from analytics.models import BaseCount
from zerver.lib.rate_limiter import RateLimitedObject
from zerver.lib.rate_limiter import rules as rate_limiter_rules
from zerver.models import AbstractPushDeviceToken, AbstractRealmAuditLog, Realm, UserProfile
from zerver.models import (
AbstractPushDevice,
AbstractPushDeviceToken,
AbstractRealmAuditLog,
Realm,
UserProfile,
)
from zerver.models.realm_audit_logs import AuditLogEventType
@@ -594,7 +600,7 @@ def has_stale_audit_log(server: RemoteZulipServer) -> bool:
return False
class RemotePushDevice(models.Model):
class RemotePushDevice(AbstractPushDevice):
"""Core bouncer server table storing registrations to receive
mobile push notifications via the bouncer server.
@@ -618,16 +624,6 @@ class RemotePushDevice(models.Model):
# that they're at most the maximum FCM / APNs payload size of 4096 bytes.
token = models.CharField(max_length=4096)
class TokenKind(models.TextChoices):
APNS = "apns", "APNs"
FCM = "fcm", "FCM"
token_kind = models.CharField(max_length=4, choices=TokenKind.choices)
# 64-bit random integer ID generated by the client; will only be
# guaranteed to be unique within the client's own table of accounts.
push_account_id = models.BigIntegerField()
# If the token is expired, the date when the bouncer learned it
# was expired via an error from the FCM/APNs server. Used to
# support delayed deletion. Null if the bouncer believes this