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
11 changes: 10 additions & 1 deletion .env.template
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
# SECURITY
# Clients must send this on protected endpoints as: Authorization: Bearer <API_KEY>
API_KEY=CHANGE_ME
# Comma-separated origins allowed to make cross-origin requests; leave unset to disable CORS
# CORS_ORIGINS=https://example.com

# DB SETTINGS
DB_HOST=localhost
DB_PORT=5432
Expand All @@ -11,4 +17,7 @@ APNS_TEAM_ID=YOUR_TEAM_ID
APNS_APP_BUNDLE_ID=YOUR_APP_BUNDLE_ID
APNS_AUTH_KEY_PATH=PATH_TO_YOUR_AUTH_KEY
# Set to true to target the APNs sandbox (development builds)
APNS_USE_SANDBOX=false
APNS_USE_SANDBOX=false

# Set to true to log SQL statements (development only; statements include device tokens)
DB_ECHO=false
25 changes: 23 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Runs lint, type checks, and tests on every push and pull request to main.
# Runs lint, type checks, unit tests, and Postgres-backed integration tests
# on every push and pull request to main.

name: Python application

Expand All @@ -16,6 +17,26 @@ jobs:

runs-on: ubuntu-latest

services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10

env:
INTEGRATION_DB_HOST: localhost
INTEGRATION_DB_PORT: "5432"
INTEGRATION_DB_NAME: postgres
INTEGRATION_DB_USERNAME: postgres
INTEGRATION_DB_PASSWORD: postgres

steps:
- uses: actions/checkout@v4
- name: Set up Python 3.11
Expand All @@ -33,6 +54,6 @@ jobs:
ruff check .
ruff format --check .
- name: Type-check with mypy
run: mypy apis entities models push services utils main.py database.py
run: mypy apis entities models push services utils main.py database.py auth.py
- name: Test with pytest
run: pytest
40 changes: 34 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,19 @@ To install this repository, follow these steps:
## Configuration
Configure the application by creating an .env file based off the template. Set the necessary parameters like database connection parameters and APNs identifiers.

Set `APNS_USE_SANDBOX=true` when testing with development builds; their device tokens are only valid against the APNs sandbox environment.
- `API_KEY` (required): the secret protected endpoints require. The server refuses to start without it.
- `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.

## Authentication
Endpoints that send pushes or expose device data require the API key:

```
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:
Expand Down Expand Up @@ -73,15 +85,18 @@ To implement push notifications in an iOS application, follow the steps below:
#### Retrieve Devices Information
- **Endpoint**: `/devices/all`
- **Method**: `GET`
- **Auth**: Requires API key

#### Clear Devices Information
- **Endpoint**: `/devices/clear`
- **Method**: `GET`
#### Delete All Devices
- **Endpoint**: `/devices`
- **Method**: `DELETE`
- **Auth**: Requires API key

## Push Endpoints
#### Send a Push Notification
- **Endpoint**: `/push/send`
- **Method**: `POST`
- **Auth**: Requires API key
- **Body**:
```json
{
Expand Down Expand Up @@ -111,11 +126,24 @@ The `Device` entity and its model represent a device registered with the server.
- `model`: The model of the device. (Optional, String)
- `localizedModel`: The model of the device as a localized string. (Optional, String)

### FastAPI CORS Middleware
This middleware was left in the project to allow for cross-origin requests during development. This decision was made to enable CORS with frontend applications during development. However, it is not recommended to enable CORS in production environments as it can lead to security vulnerabilities.
### CORS
Cross-origin requests are disabled by default. To develop a web frontend against the server, set `CORS_ORIGINS` to the exact origins you serve it from (never a wildcard in production).

**Note**: CORS is a browser security feature that prevents cross-origin requests. It does not affect requests from iOS applications.

## Testing
Run the unit and API test suite:
```bash
pytest
```

Integration tests boot the real server against a real Postgres and drive it over HTTP. Point them at any Postgres instance (for example, a disposable container):
```bash
docker run -d --name pnsf-test-pg -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:16-alpine
INTEGRATION_DB_HOST=localhost pytest tests/integration
```
CI runs both suites, plus ruff and mypy, on every push and pull request.

## Contributing
Contributions to this repository are welcome. Please follow the standard GitHub pull request process to propose changes.

Expand Down
8 changes: 4 additions & 4 deletions apis/devices.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Depends

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

Expand All @@ -25,7 +26,7 @@ def register_device(device: Device, device_service: DeviceService = Depends()):
return device_service.register_device(device)


@router.get("/all", response_model=list[Device])
@router.get("/all", response_model=list[Device], dependencies=[Depends(require_api_key)])
def get_registered_devices(
device_service: DeviceService = Depends(),
):
Expand All @@ -41,12 +42,11 @@ def get_registered_devices(
return device_service.get_registered_devices()


# FOR TESTING PURPOSES ONLY
@router.get("/clear", response_model=None)
@router.delete("", response_model=None, dependencies=[Depends(require_api_key)])
def clear_registered_devices(
device_service: DeviceService = Depends(),
):
"""
Clears all registered devices from the device service.
Deletes all registered devices.
"""
return device_service.clear_registered_devices()
2 changes: 2 additions & 0 deletions apis/push.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
from fastapi import APIRouter, Depends

from auth import require_api_key
from models import Message
from services import PushService

router = APIRouter(
prefix="/push",
tags=["push"],
dependencies=[Depends(require_api_key)],
responses={404: {"description": "Not found"}},
)

Expand Down
33 changes: 33 additions & 0 deletions auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""API key authentication for protected endpoints."""

import secrets

from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

from utils import getenv

_bearer_scheme = HTTPBearer(auto_error=False)


def require_api_key(
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
) -> None:
"""
FastAPI dependency that rejects requests without a valid API key.

Clients authenticate with an `Authorization: Bearer <API_KEY>` header.
The comparison is constant-time to avoid leaking key material through
response-timing differences.

Raises:
HTTPException: 401 if the header is missing or the key does not match.
"""
if credentials is None or not secrets.compare_digest(
credentials.credentials.encode(), getenv("API_KEY").encode()
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing API key",
headers={"WWW-Authenticate": "Bearer"},
)
7 changes: 4 additions & 3 deletions database.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import sqlalchemy
from sqlalchemy.orm import Session

from utils import getenv
from utils import getenv, getenv_bool


def _engine_str(name: str = getenv("DB_NAME")) -> str:
Expand All @@ -25,8 +25,9 @@ def _engine_str(name: str = getenv("DB_NAME")) -> str:
return f"{dialect}://{user}:{password}@{host}:{port}/{name}"


engine = sqlalchemy.create_engine(_engine_str(), echo=True)
"""Application-level SQLAlchemy database engine."""
# Application-level SQLAlchemy database engine. SQL statement logging would
# include device tokens, so it is opt-in via DB_ECHO for local debugging only.
engine = sqlalchemy.create_engine(_engine_str(), echo=getenv_bool("DB_ECHO"))


def db_session():
Expand Down
58 changes: 37 additions & 21 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
It configures middleware, adds sub-routers, and defines application-level health checks.
"""

import os
from contextlib import asynccontextmanager

from fastapi import APIRouter, FastAPI
Expand All @@ -12,44 +13,59 @@
from apis import devices, push
from entities import EntityBase
from push import shutdown_push_handler
from utils import getenv


@asynccontextmanager
async def lifespan(app: FastAPI):
if not os.getenv("API_KEY"):
raise RuntimeError(
"API_KEY environment variable must be set; protected endpoints "
"require clients to send it as 'Authorization: Bearer <API_KEY>'"
)
EntityBase.metadata.create_all(database.engine)
yield
shutdown_push_handler()


app = FastAPI(lifespan=lifespan)
def create_app() -> FastAPI:
"""
Builds the FastAPI application.

# Configure as needed
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Allows all origins
allow_credentials=True,
allow_methods=["*"], # Allows all methods
allow_headers=["*"], # Allows all headers
)
Cross-origin requests are disabled unless the CORS_ORIGINS environment
variable lists the allowed origins (comma-separated). iOS apps do not use
CORS; only enable it when serving a web frontend.
"""
app = FastAPI(lifespan=lifespan)

# List of routers
routers: list[APIRouter] = [devices.router, push.router]
cors_origins = [
origin.strip() for origin in getenv("CORS_ORIGINS", "").split(",") if origin.strip()
]
if cors_origins:
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

# Add routers to app
for router in routers:
app.include_router(router)
routers: list[APIRouter] = [devices.router, push.router]
for router in routers:
app.include_router(router)

@app.get("/health")
async def health():
return {"message": "OK"}

# Application-Level Health Checks
@app.get("/health")
async def health():
return {"message": "OK"}
@app.get("/")
async def root():
return {"message": "Hello World"}

return app

@app.get("/")
async def root():
return {"message": "Hello World"}

app = create_app()

if __name__ == "__main__":
import uvicorn
Expand Down
Loading
Loading