From 6a4b06b6f400ea684c1c2d3a923e4723d246b953 Mon Sep 17 00:00:00 2001 From: Prakhar Pratyush Date: Mon, 2 Jun 2025 15:42:53 +0530 Subject: [PATCH] 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. --- zerver/lib/export.py | 2 + zerver/migrations/0730_pushdevice.py | 58 +++++++++++++++++++++ zerver/models/__init__.py | 1 + zerver/models/push_notifications.py | 77 +++++++++++++++++++++++++++- zilencer/models.py | 20 +++----- 5 files changed, 144 insertions(+), 14 deletions(-) create mode 100644 zerver/migrations/0730_pushdevice.py diff --git a/zerver/lib/export.py b/zerver/lib/export.py index 1bd5a3dcff..c8f0152d29 100644 --- a/zerver/lib/export.py +++ b/zerver/lib/export.py @@ -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", diff --git a/zerver/migrations/0730_pushdevice.py b/zerver/migrations/0730_pushdevice.py new file mode 100644 index 0000000000..eba00e550b --- /dev/null +++ b/zerver/migrations/0730_pushdevice.py @@ -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", + ) + ], + }, + ), + ] diff --git a/zerver/models/__init__.py b/zerver/models/__init__.py index f078e11d0c..5af983a9f6 100644 --- a/zerver/models/__init__.py +++ b/zerver/models/__init__.py @@ -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 diff --git a/zerver/models/push_notifications.py b/zerver/models/push_notifications.py index 0aeeb88759..ef3cdefe55 100644 --- a/zerver/models/push_notifications.py +++ b/zerver/models/push_notifications.py @@ -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" diff --git a/zilencer/models.py b/zilencer/models.py index 5835f70520..a3dd5088c7 100644 --- a/zilencer/models.py +++ b/zilencer/models.py @@ -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