diff --git a/push-notification/.gitignore b/push-notification/.gitignore new file mode 100644 index 00000000..ced541c5 --- /dev/null +++ b/push-notification/.gitignore @@ -0,0 +1,8 @@ +assets/external/ +.web +*.py[cod] +__pycache__/ +*.db +applicationServerKey +private_key.pem +public_key.pem \ No newline at end of file diff --git a/push-notification/README.md b/push-notification/README.md new file mode 100644 index 00000000..8436467b --- /dev/null +++ b/push-notification/README.md @@ -0,0 +1,35 @@ +# push-notification + +Send Web Push API notifications from a Reflex app! + +## Setup + +``` +pip install -r requirements.txt +reflex db migrate # create database of subscribers +vapid-gen # generate keys +``` + +## Code Overview + +### Python + +* `push_notification.py` - main entry point and UI for Reflex app +* `state.py` - Reflex state for sending notifications and interacting with the database +* `models.py` - database model for storing subscription data and notification fields +* `register.py` - backend route for receiving subscription data from frontend +* `push.py` - routines for sending push notifications from the backend + +### JavaScript + +* `assets/sw.js` - service worker to be registered in the browser +* `assets/push.js` - script to request permission and send subscription data to backend + +## Further Reading + +Did you know your server can send web push notifications to your app _for free_, without subscribing to some third party push service? + +**Check out the [Web Push Book](https://web-push-book.gauntface.com) for a deep dive into the Web Push API and how it works.** + +* [webpush](https://pypi.org/project/webpush/) python library used by this example code +* [MDN Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) docs \ No newline at end of file diff --git a/push-notification/alembic.ini b/push-notification/alembic.ini new file mode 100644 index 00000000..72cc6990 --- /dev/null +++ b/push-notification/alembic.ini @@ -0,0 +1,116 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +# Use forward slashes (/) also on windows to provide an os agnostic path +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/push-notification/alembic/README b/push-notification/alembic/README new file mode 100644 index 00000000..98e4f9c4 --- /dev/null +++ b/push-notification/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/push-notification/alembic/env.py b/push-notification/alembic/env.py new file mode 100644 index 00000000..36112a3c --- /dev/null +++ b/push-notification/alembic/env.py @@ -0,0 +1,78 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = None + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/push-notification/alembic/script.py.mako b/push-notification/alembic/script.py.mako new file mode 100644 index 00000000..fbc4b07d --- /dev/null +++ b/push-notification/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/push-notification/alembic/versions/abbd91ad85c4_.py b/push-notification/alembic/versions/abbd91ad85c4_.py new file mode 100644 index 00000000..3a465602 --- /dev/null +++ b/push-notification/alembic/versions/abbd91ad85c4_.py @@ -0,0 +1,37 @@ +"""empty message + +Revision ID: abbd91ad85c4 +Revises: +Create Date: 2024-11-22 13:11:23.227016 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + +# revision identifiers, used by Alembic. +revision: str = 'abbd91ad85c4' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('subscriber', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('browser_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('auth_key', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('sub', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('enabled', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('subscriber') + # ### end Alembic commands ### diff --git a/push-notification/assets/favicon.ico b/push-notification/assets/favicon.ico new file mode 100644 index 00000000..166ae995 Binary files /dev/null and b/push-notification/assets/favicon.ico differ diff --git a/push-notification/assets/manifest.json b/push-notification/assets/manifest.json new file mode 100644 index 00000000..e0a05488 --- /dev/null +++ b/push-notification/assets/manifest.json @@ -0,0 +1,6 @@ +{ + "name": "Push Notifications Sample App", + "short_name": "mmmm...push it", + "start_url": "./index.html", + "display": "standalone", + } \ No newline at end of file diff --git a/push-notification/assets/push.js b/push-notification/assets/push.js new file mode 100644 index 00000000..f4aab24d --- /dev/null +++ b/push-notification/assets/push.js @@ -0,0 +1,56 @@ +// Public base64 to Uint +function urlBase64ToUint8Array(base64String) { + var padding = "=".repeat((4 - (base64String.length % 4)) % 4); + var base64 = (base64String + padding).replace(/\-/g, "+").replace(/_/g, "/"); + + var rawData = window.atob(base64); + var outputArray = new Uint8Array(rawData.length); + + for (var i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + +// Register a Service Worker. +function registerForPushNotifications( + registration_endpoint, + application_server_key, + browser_id, +) { + navigator.serviceWorker + .register("sw.js") + .then(function (registration) { + return navigator.serviceWorker.ready.then(function ( + serviceWorkerRegistration + ) { + return serviceWorkerRegistration.pushManager + .getSubscription() + .then(function (subscription) { + // Existing subscription found. + if (subscription) { + return subscription; + } + + // Make a new subscription. + return serviceWorkerRegistration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array( + application_server_key + ), + }); + }); + }); + }) + .then(function (subscription) { + // Send the subscription details to the server using the Fetch API. + fetch(registration_endpoint, { + method: "post", + headers: { + "Content-type": "application/json", + "X-Reflex-Browser-Id": browser_id, + }, + body: JSON.stringify(subscription), + }); + }); +} diff --git a/push-notification/assets/sw.js b/push-notification/assets/sw.js new file mode 100644 index 00000000..16365de9 --- /dev/null +++ b/push-notification/assets/sw.js @@ -0,0 +1,56 @@ +"use strict"; + +self.addEventListener("install", function (event) { + event.waitUntil(self.skipWaiting()); //will install the service worker +}); + +self.addEventListener("activate", function (event) { + event.waitUntil(self.clients.claim()); //will activate the serviceworker +}); + +// Register event listener for the 'notificationclick' event. +self.addEventListener("notificationclick", function (event) { + event.notification.close(); + + if (event.notification.data.url === undefined) { + return; + } + + event.waitUntil( + clients.matchAll({ type: "window" }).then((clientsArr) => { + // If a Window tab matching the targeted URL already exists, focus that; + const hadWindowToFocus = clientsArr.some((windowClient) => + windowClient.url === event.notification.data.url + ? (windowClient.focus(), true) + : false + ); + // Otherwise, open a new tab to the applicable URL and focus it. + if (!hadWindowToFocus) + clients + .openWindow(event.notification.data.url) + .then((windowClient) => (windowClient ? windowClient.focus() : null)); + }) + ); +}); + +self.addEventListener("push", function (event) { + event.waitUntil( + self.registration.pushManager + .getSubscription() + .then(function (subscription) { + if (!event.data) { + return; + } + + var payload = JSON.parse(event.data.text()); + return self.registration.showNotification(payload.title, { + body: payload.body, + icon: payload.icon, + data: { + url: payload.url, + }, + tag: payload.url + payload.body + payload.icon + payload.title, + }); + }) + ); +}); diff --git a/push-notification/push_notification/__init__.py b/push-notification/push_notification/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/push-notification/push_notification/models.py b/push-notification/push_notification/models.py new file mode 100644 index 00000000..7db56223 --- /dev/null +++ b/push-notification/push_notification/models.py @@ -0,0 +1,26 @@ +import reflex as rx + + +class Subscriber(rx.Model, table=True): + """Store subscription data originating from the browser.""" + + # Associated with a LocalStorage value saved in each browser. + browser_id: str + + # The Auth key returned from the browser's subscription. + auth_key: str + + # JSON-encoded subscription data saved from `push-register` endpoint. + sub: str + + # Whether this browser has enabled notifications in the app. + enabled: bool = True + + +class Notification(rx.Base): + """Fields recognized by our service worker to display a notification.""" + + title: str | None = None + body: str | None = None + icon: str | None = None + url: str | None = None \ No newline at end of file diff --git a/push-notification/push_notification/push.py b/push-notification/push_notification/push.py new file mode 100644 index 00000000..93c5f213 --- /dev/null +++ b/push-notification/push_notification/push.py @@ -0,0 +1,39 @@ +import httpx +from webpush import WebPush, WebPushSubscription + +from .models import Notification, Subscriber + +wp = WebPush(private_key="./private_key.pem", public_key="./public_key.pem") + + +def push(subscriptions: list[Subscriber], notification: Notification) -> list[str]: + """Push a notification to the given subscriptions. + + Args: + subscriptions: A list of subscriptions to push to. + notification: The notification to push. + + Returns: + A list of subscription auth keys that are no longer valid. + """ + remove_subs = [] + for sub_json in set(s.sub for s in subscriptions if s.enabled): + sub = WebPushSubscription.model_validate_json(sub_json) + print(" Sending to", sub.keys.auth) + wp_payload = wp.get( + notification.json(), + sub, + subscriber="test@example.com", + ) + req = httpx.post( + str(sub.endpoint), + data=wp_payload.encrypted, + headers=wp_payload.headers, + ) + if req.status_code == 410: + remove_subs.append(sub.keys.auth) + try: + req.raise_for_status() + except Exception as e: + print(f" Failed to send to {sub.keys.auth}: {e}") + return remove_subs diff --git a/push-notification/push_notification/push_notification.py b/push-notification/push_notification/push_notification.py new file mode 100644 index 00000000..1a1a2e8b --- /dev/null +++ b/push-notification/push_notification/push_notification.py @@ -0,0 +1,107 @@ +import reflex as rx + +from .register import add_register_push_endpoint, trigger_register +from .state import PushState + + +def registration_status(): + return rx.cond( + PushState.my_sub == None, # noqa: E711 + rx.cond( + rx.State.is_hydrated & ~PushState.waiting_for_registration, + rx.button( + "Register for push notifications", + on_click=[ + trigger_register(PushState.browser_id), + PushState.set_waiting_for_registration(True), + ], + ), + rx.spinner(), + ), + rx.hstack( + rx.text(f"Registered as {PushState.my_sub.auth_key}"), + rx.switch( + checked=PushState.my_sub.enabled, on_change=PushState.set_sub_status + ), + ), + ) + + +def notification_form(on_submit): + return rx.form( + rx.table.root( + rx.table.row( + rx.table.cell(rx.text("Title")), + rx.table.cell(rx.input(name="title")), + ), + rx.table.row( + rx.table.cell(rx.text("Body")), + rx.table.cell(rx.input(name="body")), + ), + rx.table.row( + rx.table.cell(rx.text("Icon (url)")), + rx.table.cell(rx.input(name="icon")), + ), + rx.table.row( + rx.table.cell(rx.text("URL")), + rx.table.cell(rx.input(name="url")), + ), + rx.button("Push"), + ), + reset_on_submit=True, + on_submit=on_submit, + ) + + +def other_subscriber_list() -> rx.Component: + return rx.fragment( + rx.hstack( + rx.heading("Other Subscribers"), + rx.icon_button( + "refresh-cw", + disabled=PushState.refreshing, + on_click=PushState.refresh, + ), + ), + rx.unordered_list( + rx.foreach( + PushState.other_subs, + lambda sub: rx.list_item( + rx.hstack( + rx.text(sub.auth_key), + rx.cond(sub.enabled, rx.icon("check")), + ), + ), + ), + ), + ) + + +def index() -> rx.Component: + return rx.container( + rx.color_mode.button(position="top-right"), + rx.script(src="/push.js"), + rx.moment( + interval=rx.cond(PushState.waiting_for_registration, 1000, 0), + on_change=PushState.refresh, + display="none", + ), + rx.vstack( + rx.heading("Welcome to Push Notification Tester!", size="9"), + registration_status(), + rx.cond( + PushState.my_sub != None, # noqa: E711 + notification_form(PushState.do_push_all), + ), + other_subscriber_list(), + ), + rx.logo(), + ) + + +app = rx.App() +app.add_page(index, on_load=PushState.refresh) +add_register_push_endpoint(app) + +# create db on hosting service +rx.Model.migrate() diff --git a/push-notification/push_notification/register.py b/push-notification/push_notification/register.py new file mode 100644 index 00000000..9b5ff4c5 --- /dev/null +++ b/push-notification/push_notification/register.py @@ -0,0 +1,68 @@ +import uuid +from pathlib import Path + +import reflex as rx +from fastapi import Request +from reflex.event import EventSpec +from sqlmodel import or_ +from webpush import WebPushSubscription + +from rxconfig import config +from .models import Subscriber + + +application_server_key = Path("applicationServerKey").read_text() +register_endpoint = "/register-push" +register_endpoint_url = rx.Var( + f"getBackendURL('{config.api_url}{register_endpoint}')", + _var_data=rx.vars.VarData( + imports={ + "/utils/state": [ + rx.ImportVar(tag="getBackendURL"), + ], + } + ), +) + + +async def register_push(sub: WebPushSubscription, request: Request): + try: + browser_id = request.headers["X-Reflex-Browser-Id"] + uuid.UUID(browser_id) + except (KeyError, ValueError): + return {"status": "error", "message": "Browser ID not provided"} + + with rx.session() as session: + existing_sub = session.exec( + Subscriber.select().where( + or_( + Subscriber.browser_id == browser_id, + Subscriber.auth_key == sub.keys.auth, + ) + ) + ).one_or_none() + if existing_sub is not None: + existing_sub.browser_id = browser_id + existing_sub.auth_key = sub.keys.auth + existing_sub.sub = sub.model_dump_json() + else: + session.add( + Subscriber( + browser_id=browser_id, + auth_key=sub.keys.auth, + sub=sub.model_dump_json(), + ), + ) + session.commit() + + return {"status": "ok"} + + +def add_register_push_endpoint(app: rx.App): + app.api.post(register_endpoint)(register_push) + + +def trigger_register(browser_id: rx.Var[str]) -> EventSpec: + return rx.call_script( + f"registerForPushNotifications('{register_endpoint_url}', '{application_server_key}', '{browser_id}')" + ) \ No newline at end of file diff --git a/push-notification/push_notification/state.py b/push-notification/push_notification/state.py new file mode 100644 index 00000000..d5e1ae3f --- /dev/null +++ b/push-notification/push_notification/state.py @@ -0,0 +1,63 @@ +import uuid +from typing import Any + +import reflex as rx + +from .models import Notification, Subscriber +from .push import push + + +class PushState(rx.State): + browser_id: str = rx.LocalStorage() + waiting_for_registration: bool = False + my_sub: Subscriber | None + other_subs: list[Subscriber] = [] + refreshing: bool = False + + def refresh(self): + self.refreshing = True + yield + try: + if not self.browser_id: + self.browser_id = str(uuid.uuid4()) + with rx.session() as session: + self.my_sub = session.exec( + Subscriber.select().where(Subscriber.browser_id == self.browser_id) + ).one_or_none() + self.other_subs = session.exec( + Subscriber.select().where(Subscriber.browser_id != self.browser_id) + ).all() + finally: + self.refreshing = False + if self.my_sub is not None and self.waiting_for_registration: + self.waiting_for_registration = False + return rx.toast.info("Registration successful") + + def set_sub_status(self, enabled: bool): + with rx.session() as session: + sub = session.exec( + Subscriber.select().where(Subscriber.browser_id == self.browser_id) + ).one_or_none() + if sub is not None: + sub.enabled = enabled + session.commit() + self.my_sub.enabled = enabled + self.refresh() + + def do_push_all(self, form_data: dict[str, Any]): + self.refresh() + print("Pushing from ", self.browser_id) + notification = Notification(**form_data) + remove_subs = push(self.other_subs, notification) + if remove_subs: + self._remove_subs_by_auth_key(remove_subs) + + def _remove_subs_by_auth_key(self, auth_keys: list[str]): + with rx.session() as session: + for sub in session.exec( + Subscriber.select().where(Subscriber.auth_key.in_(auth_keys)) + ).all(): + print(f" Removing old unused key {sub.auth_key}") + session.delete(sub) + session.commit() + self.refresh() \ No newline at end of file diff --git a/push-notification/requirements.txt b/push-notification/requirements.txt new file mode 100644 index 00000000..086d4403 --- /dev/null +++ b/push-notification/requirements.txt @@ -0,0 +1,2 @@ +reflex==0.6.6a1 +webpush~=1.0.2 diff --git a/push-notification/rxconfig.py b/push-notification/rxconfig.py new file mode 100644 index 00000000..d2421f74 --- /dev/null +++ b/push-notification/rxconfig.py @@ -0,0 +1,5 @@ +import reflex as rx + +config = rx.Config( + app_name="push_notification", +) \ No newline at end of file