-
Notifications
You must be signed in to change notification settings - Fork 30
/
app.js
485 lines (441 loc) · 15.8 KB
/
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
/*
* Copyright 2022 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const { Storage } = require('@google-cloud/storage');
const { GoogleAuth } = require('google-auth-library');
const jwt = require('jsonwebtoken');
const Pass = require('./pass');
const database = require('./database.js');
const express = require('express');
const upload = require('express-fileupload');
const fs = require('fs');
const path = require('path');
const URL = require('url').URL;
const NodeCache = require('node-cache');
const nanoid = require('nanoid').nanoid;
const apn = require('apn');
const fetch = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args));
const config = require('./config.js');
/**
* Internal cache of images
* @type {NodeCache}
*/
const images = new NodeCache();
/**
* Google service account credentials
* @type {Object}
*/
const credentials = config.googleServiceAccountJsonPath && require(config.googleServiceAccountJsonPath);
/**
* HTTP client for making API calls
* @type {GoogleAuth}
*/
const httpClient = new GoogleAuth({
credentials: credentials,
scopes: 'https://www.googleapis.com/auth/wallet_object.issuer',
});
/**
* Handler function for images to handle saving and hosting
* @param {string} uri The URI of the image handler
* @returns {Buffer} Request buffer
* @todo `buffer()` is deprecated, use `body.arrayBuffer()` instead
*/
async function googleImageHandler(uri) {
return (await fetch(uri)).buffer();
}
/**
*
* @param {Buffer} imageBuffer Binary string buffer for the image file
* @returns {string} URI for the image on the image host
*/
async function pkpassImageHandler(imageBuffer, imageHost) {
// Generate a randomized name for the image file
const imageName = `image-${nanoid()}.png`;
// If you're using Google Cloud Storage, this will store the image there
// Otherwise, the image is added to the local cache
if (config.googleStorageBucket) {
// Generate the object URI
imageHost = `https://storage.googleapis.com/${config.googleStorageBucket}/`;
// Create a Cloud Storage client
const storage = new Storage({ serviceAccountFile: config.googleServiceAccountJsonPath });
// Store the image in the bucket
await storage.bucket(config.googleStorageBucket).file(imageName).save(imageBuffer);
} else if (imageHost) {
if (new Set(['localhost', '127.0.0.1']).has(new URL(imageHost).hostname)) {
return;
}
// Add the image to the cache
images.set(imageName, imageBuffer);
} else {
// Called when converting pass locally without GCS configured
console.warn(`Cannot determine public host for image ${imageName} as googleStorageBucket config is not defined. The resulting pass will not be savable to Google Wallet without adding the URL for this image.`);
imageHost = '';
}
// Return the URI for the image
return `${imageHost}${imageName}`;
}
/**
* Convert from Google Wallet pass to Apple PKPass
* @param {Object} googlePass The JWT's `payload` property
* @returns {Buffer} Binary string buffer of the PKPass file data
*/
async function googleToPkPass(googlePass, apiHost) {
// Extract the pass data from the JWT payload
const pass = Pass.fromGoogle(googlePass);
pass.webServiceURL = apiHost;
pass.authenticationToken = nanoid();
database.getRepository('passes').save({
serialNumber: pass.id,
webServiceURL: pass.webServiceURL,
authenticationToken: pass.authenticationToken,
passTypeId: config.pkPassPassTypeId,
googlePrefix: pass.googlePrefix,
});
// Return a string buffer
return Buffer.from(await pass.toPkPass(googleImageHandler), 'base64');
}
function encodeJwt(payload, checkLength = true) {
const token = jwt.sign(
{
iss: credentials.client_email,
aud: 'google',
origins: ['www.example.com'],
typ: 'savetowallet',
payload: payload,
},
credentials.private_key,
{ algorithm: 'RS256' },
);
const length = token.length;
// Change the length check here to 0 to force API use,
// or something large to skip API use, although some
// browsers may not suport the request.
if (checkLength && length > 1800) {
throw `Encoded jwt too large (${length})`;
}
return token;
}
/**
* Convert a PKPass to a Google Wallet pass
* @param {Buffer} pkPass Binary string buffer for the PKPass archive
* @param {string} imageHost Image host URL
* @returns {string} Add to Google Wallet link
*/
async function pkPassToGoogle(pkPass, imageHost) {
if (!credentials) {
throw `Cannot convert to Google Wallet pass, googleServiceAccountJsonPath config must be defined`;
}
// Create the intermediary pass object
const pass = Pass.fromPkPass(pkPass);
// Convert to Google Wallet pass
const googlePass = await pass.toGoogle(async imageBuffer => pkpassImageHandler(imageBuffer, imageHost));
// Create the JWT token for the "Save to Wallet" URL, avoiding the Wallet API if the token is small enough.
// If the token is too large, strip the class from it and save it via API, and try again.
// If the token is still too large, strip all but the ID from it, and save the object via API.
let token;
try {
token = encodeJwt(googlePass);
} catch (error) {
// Create class via API.
console.log(error, '- stripping class from payload and creating via api');
try {
await httpClient.request({
url: `https://walletobjects.googleapis.com/walletobjects/v1/${pass.googlePrefix}Class/`,
method: 'POST',
data: googlePass[pass.googlePrefix + 'Classes'][0],
});
} catch (error) {
console.error('Error creating class', error);
}
// Strip class from payload.
delete googlePass[pass.googlePrefix + 'Classes'];
try {
token = encodeJwt(googlePass);
} catch (error) {
console.log(error, '- stripping object from payload and creating via api');
try {
// Create the object via API.
await httpClient.request({
url: `https://walletobjects.googleapis.com/walletobjects/v1/${pass.googlePrefix}Object/`,
method: 'POST',
data: googlePass[pass.googlePrefix + 'Objects'][0],
});
} catch (error) {
console.error('Error creating object', error);
}
// Strip all but ID from payload.
googlePass[pass.googlePrefix + 'Objects'][0] = {
id: googlePass[pass.googlePrefix + 'Objects'][0].id,
};
token = encodeJwt(googlePass, false);
}
}
// Return the Add to Google Wallet link
return 'https://pay.google.com/gp/v/save/' + token;
}
/**
* Send a string buffer to a destination, applying a specific MIME type
* @param {Response} res Response from a previous HTTP request
* @param {string} mimeType The MIME type for the send request
* @param {string} name The name of the file to send
* @param {Buffer} buffer The binary string buffer for the file contents
*/
function sendBuffer(res, mimeType, name, buffer) {
res
.set({
// Set Content-Type and Content-Disposition
'Content-Type': mimeType,
'Content-Disposition': `attachment; filename=${name}`,
})
.send(buffer);
}
// Start the Express server
const app = express();
app.use(upload());
app.use(express.json());
// Check if this is running as a demo
// If so, mount the static files middleware, using the 'public' directory
// This serves the demo page
const arg = i => (process.argv.length >= i + 1 ? process.argv[i] : undefined);
const DEMO = arg(2) === 'demo';
if (DEMO) {
app.use(express.static(path.resolve(__dirname, 'public')));
}
/**
* Get a hosted image (used by Google Wallet API during pass creation)
*/
app.get('/image/:name', (req, res) => {
sendBuffer(res, 'image/png', req.params.name, images.get(req.params.name));
});
/**
* Middleware wrapping pass conversion methods. Ensures auth header present,
* and sets some request variables for the pass conversion to access.
*/
app.use('/convert/', (req, res, next) => {
if (!DEMO && req.headers[config.authHeader] === undefined) {
if (config.authHeader === undefined) {
console.error('converterAuthHeader config must be defined and set by upstream web server');
}
res.status(401).end();
return;
} else if (!req.files) {
// No files were included in the request
res.status(400).end();
return;
}
req.passFile = req.files[Object.keys(req.files)[0]].data;
req.passText = req.passFile.toString();
req.fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
try {
next();
} catch (error) {
console.error(error);
res.status(500).send('Conversion failed, please check console output for details');
}
});
/**
* Receive a pass file and creates passes for the other supported platforms.
*/
app.post('/convert/', async (req, res) => {
if (req.passText.charAt(0) === '{') {
// The file text is a JSON object (Google Wallet pass), convert it to a PKPass
const pkPassBuffer = await googleToPkPass(JSON.parse(req.passText), req.fullUrl);
// Respond with the PKPass file
sendBuffer(res, 'application/vnd.apple.pkpass', 'pass.pkpass', pkPassBuffer);
} else {
// The file is a PKPass, convert to a Google Wallet pass
const googlePassUrl = await pkPassToGoogle(req.passFile, `${req.fullUrl}image/`);
// Redirect to the Add to Google Wallet URL
res.redirect(googlePassUrl);
}
});
/**
* Receive a pass file and uses it to update existing passes for all supported platforms.
*/
app.patch('/convert/', async (req, res) => {
let pass, googlePass;
if (req.passText.charAt(0) === '{') {
googlePass = JSON.parse(req.passText);
pass = Pass.fromGoogle(googlePass);
database
.getRepository('registrations')
.find({ serialNumber: pass.id })
.then(registrations => {
const apnProvider = new apn.Provider(config.apn);
registrations.forEach(registration => {
apnProvider.send(new apn.Notification(), registration.pushToken).then(result => {
console.log('apn push', result);
});
});
});
} else {
pass = Pass.fromPkPass(req.passFile);
googlePass = await pass.toGoogle(async imageBuffer => pkpassImageHandler(imageBuffer, `${req.fullUrl}image/`));
}
// Update the object via API.
try {
const id = googlePass[pass.googlePrefix + 'Objects'][0].id;
const response = await httpClient.request({
url: `https://walletobjects.googleapis.com/walletobjects/v1/${pass.googlePrefix}Objects/${id}`,
method: 'PATCH',
data: googlePass[pass.googlePrefix + 'Objects'][0],
});
res.json(response.data);
} catch (error) {
console.error(error);
res.status(error.response && error.response.status ? error.response.status : 400).end();
}
});
// Remaining endpoints implement the spec for updatable PKPass files,
// as per https://developer.apple.com/documentation/walletpasses/adding_a_web_service_to_update_passes
/**
* Middleware wrapping the endpoints for managing PKPass updates.
* Validates auth token against database, and assigns matching pass record to the request.
*/
app.use('/v1/', (req, res, next) => {
const prefix = 'ApplePass ';
const header = req.headers['http_authorization'];
const authenticationToken = header && header.indexOf(prefix) === 0 ? header.replace(prefix, '') : '';
database
.getRepository('passes')
.findOne({
where: {
serialNumber: req.params.serialNumber,
passTypeId: req.params.passTypeId,
authenticationToken: authenticationToken,
},
})
.then(pass => {
if (pass === null) {
res.status(401).end();
} else {
req.passRecord = pass;
next();
}
});
});
/**
* Called when PKPass is added to iOS device - create a registration record with push token we can send when pass is later updated.
*/
app.post('/v1/devices/:deviceId/registrations/:passTypeId/:serialNumber', (req, res) => {
const uuid = `${req.params.device_id}-${req.params.serial_number}`;
const registrations = database.getRepository('registrations');
registrations.count({ where: { uuid } }).then(count => {
const status = count === 0 ? 201 : 200;
if (status === 201) {
registrations.save({
uuid: uuid,
deviceId: req.params.passTypeId,
passTypeId: req.params.passTypeId,
serialNumber: req.params.serialNumber,
pushToken: req.body['pushToken'],
});
}
res.status(status).end();
});
});
/**
* Called when updated PKPass is requested.
*/
app.get('/v1/passes/:pass_type_id/:serial_number', async (req, res) => {
// Retrieve pass content from Wallet API.
httpClient
.request({
url: `https://walletobjects.googleapis.com/walletobjects/v1/${req.passRecord.googlePrefix}Object/${config.googleIssuerId}.${req.passRecord.serialNumber}`,
method: 'GET',
})
.then(response => {
// Convert to PKPass and send as response.
Pass.fromGoogle({
[`${req.passRecord.googlePrefix}Classes`]: [response.data.classReference],
[`${req.passRecord.googlePrefix}Objects`]: [response.data],
})
.toPkPass(googleImageHandler)
.then(pkPassBuffer => {
sendBuffer(res, 'application/vnd.apple.pkpass', 'pass.pkpass', Buffer.from(pkPassBuffer, 'base64'));
});
})
.catch(error => {
console.error('Updated PKPass requested, but could not retrieve', error);
res.status(400).end();
});
});
/**
* Called when PKPass is removed from device - remove registration record.
*/
app.delete('/v1/devices/:device_id/registrations/:pass_type_id/:serial_number', (req, res) => {
const uuid = `${req.params.device_id}-${req.params.serial_number}`;
const registrations = database.getRepository('registrations');
registrations.findOne({ where: { uuid } }).then(registration => {
let status = 401;
if (registration != null) {
status = 201;
registrations.remove(registration);
}
res.status(status).end();
});
});
/**
* Handles local conversion of passes on the command-line
* @param {string} inputPath Path to input pass
* @param {string} outputPath Path to save converted pass
*/
async function convertPassLocal(inputPath, outputPath) {
// Converts the pass to stringified JSON
const stringify = pass => JSON.stringify(pass, null, 2);
const ext = path.extname(inputPath);
let pass;
switch (ext) {
case '.pkpass':
// Convert a PKPass to a Google Wallet pass
pass = await Pass.fromPkPass(fs.readFileSync(inputPath)).toGoogle(pkpassImageHandler);
break;
case '.json':
// Convert a Google Wallet pass to a PKPass
pass = await Pass.fromGoogle(require(inputPath)).toPkPass(googleImageHandler);
break;
}
if (outputPath) {
// Write to local filesystem
if (ext === '.pkpass') {
// Convert the pass to a string
pass = stringify(pass);
}
fs.writeFileSync(outputPath, pass);
} else {
// Write the JSON output to the console
if (ext === '.json') {
// For PKPass, use the pass.json contents
pass = JSON.parse(new require('adm-zip')(pass).getEntry('pass.json').getData().toString('utf8'));
}
console.log(stringify(pass));
}
}
/**
* Entrypoint - Handles command-line and Express server invocation
*/
async function main() {
if (arg(2) && !DEMO) {
// Command-line invocation
await convertPassLocal(arg(2), arg(3));
} else {
database.initialize();
// Express server invocation
app.listen(config.bindPort, config.bindHost, () => {
console.log(`Listening on http://${config.bindHost}:${config.bindPort}`);
});
}
}
main().catch(console.error);