Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .env.template
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# SERVER (defaults shown; used by `python main.py`)
# HOST=127.0.0.1
# PORT=8000

# SECURITY
# Clients must send this on protected endpoints as: Authorization: Bearer <API_KEY>
API_KEY=CHANGE_ME
Expand All @@ -11,11 +15,14 @@ DB_NAME=postgres
DB_USERNAME=postgres
DB_PASSWORD=postgres

# APNS SETTINGS
# APNS SETTINGS (token auth, recommended)
APNS_KEY_ID=YOUR_KEY_ID
APNS_TEAM_ID=YOUR_TEAM_ID
APNS_APP_BUNDLE_ID=YOUR_APP_BUNDLE_ID
APNS_AUTH_KEY_PATH=PATH_TO_YOUR_AUTH_KEY
# Certificate auth instead: PEM file with certificate and private key
# APNS_CERT_PATH=path/to/cert.pem
# APNS_CERT_PASSWORD=
# Set to true to target the APNs sandbox (development builds)
APNS_USE_SANDBOX=false

Expand Down
95 changes: 51 additions & 44 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,48 +1,55 @@
# Push Notification Server Framework

## Introduction
`PushNotificationServerFramework` is an open-source project designed to offer a template for creating remote push notification servers for iOS applications via [Apple Push Notification service](https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/sending_notification_requests_to_apns).

It simplifies the process of registering devices with the server and provides services for storing, fetching, and clearing device information, in addition to providing endpoints for sending push notifications to these devices.
`PushNotificationServerFramework` is an open-source template for building remote push notification servers for iOS applications using the [Apple Push Notification service](https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/sending_notification_requests_to_apns). It handles device registration and delivers notifications through APNs.

### Features
- **Premade Models and Entities**: Includes premade models and entities for device and message information.
- **Device Endpoints**: Facilitates registering and fetching devices with the server.
- **Push Endpoints**: Provides endpoints for sending push notifications to devices.
- **Data Persistence**: Utilizes SQLAlchemy ORM for managing database operations.
- **Pydantic Models**: Ensures validation and serialization of device and message entities.
- **Modified APNS2**: Includes a modified version of the Python `apns2` package, updated for Python 3.11 compatibility.
- **FastAPI Framework**: Leverages the FastAPI framework for efficient and easy server development.
- **Device registration**: Idempotent registration keyed by device token, with optional device metadata.
- **Push delivery**: Per-token results; tokens Apple reports as gone are pruned automatically.
- **APNs client**: Persistent HTTP/2 connection, token (.p8) or certificate authentication, current APNs push types and error reasons.
- **API key authentication**: Bearer-token protection on push and admin endpoints.
- **Data persistence**: SQLAlchemy 2.0 with PostgreSQL.
- **Tested**: Unit, API, and Postgres-backed integration suites run in CI with lint and type checks.

### Project Structure
- `apis/`: Contains the API endpoints for the server.
- `entities/`: Contains the SQLAlchemy entities for the server.
- `models/`: Contains the Pydantic models for the server.
- `push/`: Contains the push notification services for the server.
- `services/`: Contains the services for the server.
- `utils/`: Contains utility functions for the server.
- `apis/`: API endpoints.
- `auth.py`: API key dependency.
- `database.py`: Database engine and session dependency.
- `entities/`: SQLAlchemy entities.
- `models/`: Pydantic request and response models.
- `push/`: APNs client and push handling.
- `services/`: Application services.
- `tests/`: Unit, API, and integration tests.
- `utils/`: Environment helpers.

## Prerequisites
Before installing this repository, ensure you have the following:
- Python 3.11
- Pip package manager
- Python 3.11+
- PostgreSQL (any reachable instance; a disposable Docker one is shown below)

## Installation
To install this repository, follow these steps:
## Quickstart
```bash
git clone https://github.com/j0shcap/PushNotificationServerFramework.git
cd PushNotificationServerFramework
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.template .env # then fill in your values
docker run -d --name pnsf-postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:16-alpine
python main.py
```
Interactive API documentation is served at `http://127.0.0.1:8000/docs`.

1. Clone the repository:
```bash
git clone https://github.com/JoshCap20/PushNotificationServerFramework.git
```
2. Install required dependencies:
```bash
pip install -r requirements.txt
```
## Running in Production
`python main.py` binds to `127.0.0.1:8000`; set `HOST` and `PORT` to override. Behind a reverse proxy, run uvicorn directly with workers:
```bash
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 2
```
Terminate TLS at the proxy — the API key travels in a header and must never cross plain HTTP.

## Configuration
Configure the application by creating an .env file based off the template. Set the necessary parameters like database connection parameters and APNs identifiers.
Configure the application through the `.env` file. Database and APNs identifiers are required; the notable options:

- `API_KEY` (required): the secret protected endpoints require. The server refuses to start without it.
- `APNS_CERT_PATH`: switches from token auth (the default, recommended by Apple) to certificate auth. Points to a PEM file containing the provider certificate and private key; `APNS_CERT_PASSWORD` supplies its passphrase if any.
- `APNS_USE_SANDBOX`: set to `true` when testing with development builds; their device tokens are only valid against the APNs sandbox environment.
- `CORS_ORIGINS`: comma-separated origins allowed to make cross-origin requests. Unset by default, which disables CORS entirely — iOS apps do not use CORS; only set this when serving a web frontend.
- `DB_ECHO`: set to `true` to log SQL statements during development. Off by default because statements include device tokens.
Expand All @@ -56,20 +63,18 @@ Authorization: Bearer <API_KEY>

Requests without a valid key receive `401 Unauthorized`. `/devices/register` is deliberately open: it is called by the iOS app itself, and shipping the key inside the app binary would expose it. The worst an unauthenticated caller can do is register junk tokens, which APNs pruning removes on the next push.

## Running the Server
To start the server, run the following command:
```bash
python main.py
```

## Client-Side Implementation
To implement push notifications in an iOS application, follow the steps below:
1. Register the application for push notifications.
- See [Apple Developer documentation](https://developer.apple.com/documentation/usernotifications/registering_your_app_with_apns) for more information.
To implement push notifications in an iOS application:
1. Register the application for push notifications ([Apple Developer documentation](https://developer.apple.com/documentation/usernotifications/registering_your_app_with_apns)).
2. Request permission from the user to send push notifications.
3. Register the device with the server.
- Post the device token to the `/devices/register` endpoint.

3. Post the device token to the `/devices/register` endpoint. APNs hands the app the token as raw `Data`; convert it to the hex string this server expects:
```swift
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
// POST ["token": token] to /devices/register
}
```

## Device Endpoints
#### Register a Device
Expand All @@ -78,9 +83,11 @@ To implement push notifications in an iOS application, follow the steps below:
- **Body**:
```json
{
"token": "unique_device_id",
"token": "hex_apns_device_token"
}
```
Also accepts the optional device fields listed under Design Notes. Server-managed fields (`id`, timestamps) are ignored if sent.
- **Response**: The registered device, including its server-assigned `id` and timestamps. Registration is idempotent by token: re-registering updates the stored fields.

#### Retrieve Devices Information
- **Endpoint**: `/devices/all`
Expand Down Expand Up @@ -154,4 +161,4 @@ This project is licensed under the [MIT License](LICENSE).
- Pydantic for data validation and serialization.
- SQLAlchemy ORM for database management.
- FastAPI for the server framework.
- Modified Python `apns2` package for handling Apple Push Notification services.
- The Python `apns2` package, from which the vendored APNs client is derived.
8 changes: 4 additions & 4 deletions apis/devices.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends

from auth import require_api_key
from models import Device
from models import Device, DeviceRegistration
from services import DeviceService

router = APIRouter(
Expand All @@ -12,18 +12,18 @@


@router.post("/register", response_model=Device)
def register_device(device: Device, device_service: DeviceService = Depends()):
def register_device(registration: DeviceRegistration, device_service: DeviceService = Depends()):
"""
Registers a new device with the push notification framework.

Args:
device (Device): The device to register.
registration (DeviceRegistration): The device information to register.
device_service (DeviceService): An instance of the DeviceService class. Injected by FastAPI.

Returns:
Device: The registered device.
"""
return device_service.register_device(device)
return device_service.register_device(registration)


@router.get("/all", response_model=list[Device], dependencies=[Depends(require_api_key)])
Expand Down
7 changes: 2 additions & 5 deletions database.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,10 @@
from utils import getenv, getenv_bool


def _engine_str(name: str = getenv("DB_NAME")) -> str:
def _engine_str() -> str:
"""
Helper function for reading settings from environment variables to produce connection string.

Arguments:
name (str): The name of the database. Defaults to the value of the
"DB_NAME" environment variable.

Returns:
str: The connection string for the database.
"""
Expand All @@ -22,6 +18,7 @@ def _engine_str(name: str = getenv("DB_NAME")) -> str:
password = getenv("DB_PASSWORD")
host = getenv("DB_HOST")
port = getenv("DB_PORT")
name = getenv("DB_NAME")
return f"{dialect}://{user}:{password}@{host}:{port}/{name}"


Expand Down
21 changes: 10 additions & 11 deletions entities/device_entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column

from models import Device
from models import Device, DeviceRegistration

from .entity_base import EntityBase

Expand Down Expand Up @@ -52,15 +52,14 @@ def to_model(self) -> Device:
)

@classmethod
def from_model(cls, model: Device) -> "DeviceEntity":
def from_registration(cls, registration: DeviceRegistration) -> "DeviceEntity":
"""Builds a new entity from client-supplied fields; the id and
timestamps are server-managed and never taken from the request."""
return cls(
id=model.id,
token=model.token,
name=model.name,
systemName=model.systemName,
systemVersion=model.systemVersion,
model=model.model,
localizedModel=model.localizedModel,
created_at=model.created_at,
updated_at=model.updated_at,
token=registration.token,
name=registration.name,
systemName=registration.systemName,
systemVersion=registration.systemVersion,
model=registration.model,
localizedModel=registration.localizedModel,
)
2 changes: 1 addition & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,4 @@ async def root():
if __name__ == "__main__":
import uvicorn

uvicorn.run(app)
uvicorn.run(app, host=getenv("HOST", "127.0.0.1"), port=int(getenv("PORT", "8000")))
4 changes: 2 additions & 2 deletions models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
This module contains the Device and Message models for serializing and deserializing data.
"""

from .device import Device
from .device import Device, DeviceRegistration
from .message import Message

__all__ = ["Device", "Message"]
__all__ = ["Device", "DeviceRegistration", "Message"]
21 changes: 15 additions & 6 deletions models/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,37 @@
from pydantic import BaseModel


class Device(BaseModel):
class DeviceRegistration(BaseModel):
"""
Represents a device that can receive push notifications.
Client-supplied device information for registration.

Attributes:
id (int | None): The unique identifier for the device.
token (str): The device token used for push notifications. Required.
name (str): The name of the device.
systemName (str): The name of the operating system running on the device.
systemVersion (str): The version of the operating system running on the device.
model (str): The model of the device.
localizedModel (str): The localized model of the device.
created_at (datetime | None): The date and time the device was created.
updated_at (datetime | None): The date and time the device was last updated.
"""

id: int | None = None
token: str
name: str | None = None
systemName: str | None = None
systemVersion: str | None = None
model: str | None = None
localizedModel: str | None = None


class Device(DeviceRegistration):
"""
A registered device, including server-managed fields.

Attributes:
id (int): The unique identifier for the device. Assigned by the server.
created_at (datetime | None): When the device was first registered.
updated_at (datetime | None): When the device was last updated.
"""

id: int
created_at: datetime | None = None
updated_at: datetime | None = None
11 changes: 9 additions & 2 deletions push/apn_handler/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
from .client import APNsClient, Notification
from .credentials import Credentials, TokenCredentials
from .credentials import CertificateCredentials, Credentials, TokenCredentials
from .payload import Payload

__all__ = ["APNsClient", "Credentials", "Notification", "Payload", "TokenCredentials"]
__all__ = [
"APNsClient",
"CertificateCredentials",
"Credentials",
"Notification",
"Payload",
"TokenCredentials",
]
Loading
Loading