Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(passkit_server): Passkit Backend #76

Merged
merged 14 commits into from
Sep 25, 2024
Merged
Show file tree
Hide file tree
Changes from 6 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
7 changes: 7 additions & 0 deletions passkit_server/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# https://dart.dev/guides/libraries/private-files
# Created by `dart pub`
.dart_tool/

# Avoid committing pubspec.lock for library packages; see
# https://dart.dev/guides/libraries/private-files#pubspeclock.
pubspec.lock
3 changes: 3 additions & 0 deletions passkit_server/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## 1.0.0

- Initial version.
39 changes: 39 additions & 0 deletions passkit_server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<!--
This README describes the package. If you publish this package to pub.dev,
this README's contents appear on the landing page for your package.
For information about how to write a good package README, see the guide for
[writing package pages](https://dart.dev/guides/libraries/writing-package-pages).
For general information about developing packages, see the Dart guide for
[creating packages](https://dart.dev/guides/libraries/create-library-packages)
and the Flutter guide for
[developing packages and plugins](https://flutter.dev/developing-packages).
-->

TODO: Put a short description of the package here that helps potential users
know whether this package might be useful for them.

## Features

TODO: List what your package can do. Maybe include images, gifs, or videos.

## Getting started

TODO: List prerequisites and provide or point to information on how to
start using the package.

## Usage

TODO: Include short and useful examples for package users. Add longer examples
to `/example` folder.

```dart
const like = 'sample';
```

## Additional information

TODO: Tell users more about the package: where to find more information, how to
contribute to the package, how to file issues, what response they can expect
from the package authors, and more.
27 changes: 27 additions & 0 deletions passkit_server/analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
include: package:lints/recommended.yaml

linter:
rules:
prefer_single_quotes: true
unawaited_futures: true
sort_constructors_first: true
use_key_in_widget_constructors: true
use_super_parameters: true
use_colored_box: true
use_decorated_box: true
no_leading_underscores_for_local_identifiers: true
require_trailing_commas: true
flutter_style_todos: true
sort_pub_dependencies: true

analyzer:
language:
strict-casts: true
strict-inference: true
strict-raw-types: true
errors:
missing_required_param: error
missing_return: error
todo: ignore
exclude:
- "**.g.dart"
1 change: 1 addition & 0 deletions passkit_server/example/passkit_server_example.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

1 change: 1 addition & 0 deletions passkit_server/lib/passkit_server.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export 'src/passkit_server.dart';
74 changes: 74 additions & 0 deletions passkit_server/lib/src/passkit_backend.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import 'dart:async';

import 'package:passkit/passkit.dart';

abstract class PassKitBackend {
/// Saves JSON that gets send to `/v1/log`
Future<void> logMessage(Map<String, dynamic> message);

/// "/v1/passes/{identifier}/{serial}""
Future<UpdatablePassResponse?> returnUpdatablePasses(
String identifier,
String serial,
String updatedSince,
);

/// URL must end with "v1/passes/{identifier}/{serial}"
/// Pass delivery
///
/// GET /v1/passes/<typeID>/<serial#>
/// Header: Authorization: ApplePass <authenticationToken>
///
/// server response:
/// --> if auth token is correct: 200, with pass data payload
/// --> if auth token is incorrect: 401
Future<PkPass?> getUpdatedPass(
String identifier,
String serial,
);

Future<NotificationRegistrationReponse> setupNotifications(
String deviceId,
String passTypeId,
String serialNumber,
String pushToken,
);

Future<bool> stopNotifications(
String deviceId,
String passTypeId,
String serialNumber,
);

/// Should return true if the [serial] and [authToken] match and are valid.
/// Otherwise it should return false.
FutureOr<bool> isValidAuthToken(String serial, String authToken);
}

class UpdatablePassResponse {
UpdatablePassResponse._(this.response, {this.tag, this.ids});

factory UpdatablePassResponse.matchingPasses(String tag, List<String> ids) {
return UpdatablePassResponse._(200, tag: tag, ids: ids);
}

factory UpdatablePassResponse.noMatchingPasses() =>
UpdatablePassResponse._(204);
factory UpdatablePassResponse.unknownDeviceIdentifier() =>
UpdatablePassResponse._(404);

final String? tag;
final List<String>? ids;

final int response;
}

class DevPassKitBackend extends PassKitBackend {
@override
void noSuchMethod(Invocation invocation) {}
}

enum NotificationRegistrationReponse {
created,
existing,
}
212 changes: 212 additions & 0 deletions passkit_server/lib/src/passkit_server.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import 'dart:async';
import 'dart:convert';

import 'package:passkit_server/src/passkit_backend.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';

extension PasskitServerExtension on Router {
void addPassKitServer(PassKitBackend backend) {
post(
'/v1/devices/<deviceID>/registrations/<passTypeID>/<serial>',
setupNotifications(backend),
);
get(
'/v1/devices/<deviceID>/registrations/<typeID>',
getListOfUpdatablePasses(backend),
);
delete(
'/v1/devices/<deviceID>/registrations/<passTypeID>/<serial>',
stopNotifications(backend),
);
get('/v1/passes/<identifier>/<serial>', getLatestVersion(backend));
post('/v1/log', logMessages(backend));
}
}
Comment on lines +8 to +25
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably be a shelf routing middleware. That ensures better compatibility with dart_frog. For shelf_router compatibility should still be maintained though, but I guess that's easy enough to do and requires just some documentation or example code. That way, this package also doesn't depend on the shelf_router package itself.

References:


/// Pass delivery
///
/// GET /v1/passes/<typeID>/<serial#>
/// Header: Authorization: ApplePass <authenticationToken>
///
/// server response:
/// --> if auth token is correct: 200, with pass data payload
/// --> if auth token is incorrect: 401
Function getLatestVersion(PassKitBackend backend) {
return (Request request, String identifier, String serial) async {
final response = await backend.validateAuthToken(request, serial);
if (response != null) {
return response;
}

final pass = await backend.getUpdatedPass(identifier, serial);

if (pass == null) {
return Response.unauthorized(null);
}

return Response.ok(pass.sourceData);
};
}

/// Logging/Debugging from the device
///
/// log an error or unexpected server behavior, to help with server debugging
/// POST /v1/log
/// JSON payload: { "description" : <human-readable description of error> }
///
/// server response: 200
Function logMessages(PassKitBackend backend) {
return (Request request) async {
final content = await request.readAsString();
// There's no need to wait for the log message to be written, instead return
// a 200 status code response right away
unawaited(
DevPassKitBackend()
.logMessage(jsonDecode(content) as Map<String, dynamic>),
);
return Response.ok(null);
};
}

/// Registration
/// register a device to receive push notifications for a pass
///
/// POST /v1/devices/<deviceID>/registrations/<typeID>/<serial#>
/// Header: Authorization: ApplePass <authenticationToken>
/// JSON payload:
/// ```json
/// { "pushToken" : <push token, which the server needs to send push notifications to this device> }
/// ```
///
/// Params definition
/// [deviceId] : the device's identifier
/// [passTypeId] : the bundle identifier for a class of passes, sometimes refered
/// to as the pass topic, e.g. pass.com.apple.backtoschoolgift,
/// registered with WWDR
/// [serialNumber] : the pass' serial number
/// `pushToken` (from the [request]): the value needed for Apple Push Notification service
///
/// server action: if the authentication token is correct, associate the given
/// push token and device identifier with this pass
/// server response:
/// --> if registration succeeded: 201
/// --> if this serial number was already registered for this device: 304
/// --> if not authorized: 401
Function setupNotifications(PassKitBackend backend) {
return (
Request request,
String deviceId,
String passTypeId,
String serialNumber,
) async {
final response = await backend.validateAuthToken(request, serialNumber);
if (response != null) {
return response;
}
final body = await request.readAsString();
final bodyJson = jsonDecode(body) as Map<String, dynamic>;
final pushToken = bodyJson['pushToken'] as String?;
if (pushToken == null) {
// TODO(anyone): include more information in debug mode?
return Response.badRequest();
}

final notificationRegistrationReponse = await backend.setupNotifications(
deviceId,
passTypeId,
serialNumber,
pushToken,
);

return switch (notificationRegistrationReponse) {
NotificationRegistrationReponse.created => Response(201),
NotificationRegistrationReponse.existing => Response.ok(null),
};
};
}

/// Unregister
///
/// unregister a device to receive push notifications for a pass
///
/// DELETE /v1/devices/<deviceID>/registrations/<passTypeID>/<serial#>
/// Header: Authorization: ApplePass <authenticationToken>
///
/// server action: if the authentication token is correct, disassociate the
/// device from this pass
/// server response:
/// --> if disassociation succeeded: 200
/// --> if not authorized: 401
Function stopNotifications(PassKitBackend backend) {
return (
Request request,
String deviceId,
String passTypeId,
String serialNumber,
) async {
final backend = DevPassKitBackend();
final response = await backend.validateAuthToken(request, serialNumber);
if (response != null) {
return response;
}
final success = await backend.stopNotifications(
deviceId,
passTypeId,
serialNumber,
);
if (success) {
return Response.ok(null);
}

// TODO(anyone): Is this correct?
return Response.internalServerError();
};
}

/// Updatable passes
///
/// get all serial #s associated with a device for passes that need an update
/// Optionally with a query limiter to scope the last update since
///
/// GET /v1/devices/<deviceID>/registrations/<typeID>
/// GET /v1/devices/<deviceID>/registrations/<typeID>?passesUpdatedSince=<tag>
///
/// server action: figure out which passes associated with this device have been modified since the supplied tag (if no tag provided, all associated serial #s)
/// server response:
/// --> if there are matching passes: 200, with JSON payload: { "lastUpdated" : <new tag>, "serialNumbers" : [ <array of serial #s> ] }
/// --> if there are no matching passes: 204
/// --> if unknown device identifier: 404
Function getListOfUpdatablePasses(PassKitBackend backend) {
return (Request request, String deviceId, String typeId) async {
return Response.notFound(null);
};
}

extension on Request {
String? getApplePassToken() {
var header = headers['Authorization'];
if (header?.startsWith('ApplePass ') == true) {
var token = header?.split(' ').lastOrNull;
return token;
}

return null;
}
}

extension on PassKitBackend {
Future<Response?> validateAuthToken(Request request, String serial) async {
var token = request.getApplePassToken();

if (token == null) {
return Response.unauthorized(null);
}

final isValidToken = await isValidAuthToken(serial, token);
if (!isValidToken) {
return Response.unauthorized(null);
}
return null;
}
}
16 changes: 16 additions & 0 deletions passkit_server/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
name: passkit_server
description: A starting point for Dart libraries or applications.
version: 0.0.1
repository: https://github.com/ueman/passkit

environment:
sdk: ^3.4.4

dependencies:
passkit: ^0.0.4
shelf: ^1.4.2
shelf_router: ^1.1.4

dev_dependencies:
lints: ^4.0.0
test: ^1.24.0
Loading