1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
/* |
4
|
|
|
* This file is part of the Passbook package. |
5
|
|
|
* |
6
|
|
|
* (c) Eymen Gunay <[email protected]> |
7
|
|
|
* |
8
|
|
|
* For the full copyright and license information, please view the LICENSE |
9
|
|
|
* file that was distributed with this source code. |
10
|
|
|
*/ |
11
|
|
|
|
12
|
|
|
namespace Passbook; |
13
|
|
|
|
14
|
|
|
use Exception; |
15
|
|
|
use FilesystemIterator; |
16
|
|
|
use Passbook\Certificate\P12; |
17
|
|
|
use Passbook\Certificate\WWDR; |
18
|
|
|
use Passbook\Exception\FileException; |
19
|
|
|
use Passbook\Exception\PassInvalidException; |
20
|
|
|
use Passbook\Pass\Image; |
21
|
|
|
use RecursiveDirectoryIterator; |
22
|
|
|
use RecursiveIteratorIterator; |
23
|
|
|
use SplFileObject; |
24
|
|
|
use ZipArchive; |
25
|
|
|
|
26
|
|
|
/** |
27
|
|
|
* PassFactory - Creates .pkpass files |
28
|
|
|
* |
29
|
|
|
* @author Eymen Gunay <[email protected]> |
30
|
|
|
*/ |
31
|
|
|
class PassFactory |
32
|
|
|
{ |
33
|
|
|
/** |
34
|
|
|
* Output path for generated pass files |
35
|
|
|
* |
36
|
|
|
* @var string |
37
|
|
|
*/ |
38
|
|
|
protected $outputPath = ''; |
39
|
|
|
|
40
|
|
|
/** |
41
|
|
|
* Overwrite if pass exists |
42
|
|
|
* |
43
|
|
|
* @var bool |
44
|
|
|
*/ |
45
|
|
|
protected $overwrite = false; |
46
|
|
|
|
47
|
|
|
/** |
48
|
|
|
* Pass type identifier |
49
|
|
|
* |
50
|
|
|
* @var string |
51
|
|
|
*/ |
52
|
|
|
protected $passTypeIdentifier; |
53
|
|
|
|
54
|
|
|
/** |
55
|
|
|
* Team identifier |
56
|
|
|
* |
57
|
|
|
* @var string |
58
|
|
|
*/ |
59
|
|
|
protected $teamIdentifier; |
60
|
|
|
|
61
|
|
|
/** |
62
|
|
|
* Organization name |
63
|
|
|
* |
64
|
|
|
* @var string |
65
|
|
|
*/ |
66
|
|
|
protected $organizationName; |
67
|
|
|
|
68
|
|
|
/** |
69
|
|
|
* P12 file |
70
|
|
|
* |
71
|
|
|
* @var \Passbook\Certificate\P12Interface |
72
|
|
|
*/ |
73
|
|
|
protected $p12; |
74
|
|
|
|
75
|
|
|
/** |
76
|
|
|
* WWDR file |
77
|
|
|
* |
78
|
|
|
* @var \Passbook\Certificate\WWDRInterface |
79
|
|
|
*/ |
80
|
|
|
protected $wwdr; |
81
|
|
|
|
82
|
|
|
/** |
83
|
|
|
* @var bool - skip signing the pass; should only be used for testing |
84
|
|
|
*/ |
85
|
|
|
protected $skipSignature; |
86
|
|
|
|
87
|
|
|
/** |
88
|
|
|
* @var PassValidatorInterface |
89
|
|
|
*/ |
90
|
|
|
private $passValidator; |
91
|
|
|
|
92
|
|
|
/** |
93
|
|
|
* Pass file extension |
94
|
|
|
* |
95
|
|
|
* @var string |
96
|
|
|
*/ |
97
|
|
|
const PASS_EXTENSION = '.pkpass'; |
98
|
|
|
|
99
|
|
|
public function __construct($passTypeIdentifier, $teamIdentifier, $organizationName, $p12File, $p12Pass, $wwdrFile) |
100
|
|
|
{ |
101
|
|
|
// Required pass information |
102
|
|
|
$this->passTypeIdentifier = $passTypeIdentifier; |
103
|
|
|
$this->teamIdentifier = $teamIdentifier; |
104
|
|
|
$this->organizationName = $organizationName; |
105
|
|
|
|
106
|
|
|
// Create certificate objects |
107
|
|
|
$this->p12 = new P12($p12File, $p12Pass); |
108
|
|
|
$this->wwdr = new WWDR($wwdrFile); |
109
|
|
|
|
110
|
|
|
// By default use the PassValidator |
111
|
|
|
$this->passValidator = new PassValidator(); |
112
|
|
|
} |
113
|
|
|
|
114
|
|
|
/** |
115
|
|
|
* Set outputPath |
116
|
|
|
* |
117
|
|
|
* @param string |
118
|
|
|
* |
119
|
|
|
* @return $this |
120
|
|
|
*/ |
121
|
|
|
public function setOutputPath($outputPath) |
122
|
|
|
{ |
123
|
|
|
$this->outputPath = $outputPath; |
124
|
|
|
|
125
|
|
|
return $this; |
126
|
|
|
} |
127
|
|
|
|
128
|
|
|
/** |
129
|
|
|
* Get outputPath |
130
|
|
|
* |
131
|
|
|
* @return string |
132
|
|
|
*/ |
133
|
|
|
public function getOutputPath() |
134
|
|
|
{ |
135
|
|
|
return $this->outputPath; |
136
|
|
|
} |
137
|
|
|
|
138
|
|
|
/** |
139
|
|
|
* The output path with a directory separator on the end. |
140
|
|
|
* |
141
|
|
|
* @return string |
142
|
|
|
*/ |
143
|
|
|
public function getNormalizedOutputPath() |
144
|
|
|
{ |
145
|
|
|
return rtrim($this->outputPath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; |
146
|
|
|
} |
147
|
|
|
|
148
|
|
|
/** |
149
|
|
|
* Set overwrite |
150
|
|
|
* |
151
|
|
|
* @param boolean |
152
|
|
|
* |
153
|
|
|
* @return $this |
154
|
|
|
*/ |
155
|
|
|
public function setOverwrite($overwrite) |
156
|
|
|
{ |
157
|
|
|
$this->overwrite = $overwrite; |
158
|
|
|
|
159
|
|
|
return $this; |
160
|
|
|
} |
161
|
|
|
|
162
|
|
|
/** |
163
|
|
|
* Get overwrite |
164
|
|
|
* |
165
|
|
|
* @return boolean |
166
|
|
|
*/ |
167
|
|
|
public function isOverwrite() |
168
|
|
|
{ |
169
|
|
|
return $this->overwrite; |
170
|
|
|
} |
171
|
|
|
|
172
|
|
|
/** |
173
|
|
|
* Set skip signature |
174
|
|
|
* |
175
|
|
|
* When set, the pass will not be signed when packaged. This should only |
176
|
|
|
* be used for testing. |
177
|
|
|
* |
178
|
|
|
* @param boolean |
179
|
|
|
* |
180
|
|
|
* @return $this |
181
|
|
|
*/ |
182
|
|
|
public function setSkipSignature($skipSignature) |
183
|
|
|
{ |
184
|
|
|
$this->skipSignature = $skipSignature; |
185
|
|
|
|
186
|
|
|
return $this; |
187
|
|
|
} |
188
|
|
|
|
189
|
|
|
/** |
190
|
|
|
* Get skip signature |
191
|
|
|
* |
192
|
|
|
* @return boolean |
193
|
|
|
*/ |
194
|
|
|
public function getSkipSignature() |
|
|
|
|
195
|
|
|
{ |
196
|
|
|
return $this->skipSignature; |
197
|
|
|
} |
198
|
|
|
|
199
|
|
|
/** |
200
|
|
|
* Set an implementation of PassValidatorInterface to validate the pass |
201
|
|
|
* before packaging. When set to null, no validation is performed when |
202
|
|
|
* packaging the pass. |
203
|
|
|
* |
204
|
|
|
* @param PassValidatorInterface|null $passValidator |
205
|
|
|
* |
206
|
|
|
* @return $this |
207
|
|
|
*/ |
208
|
|
|
public function setPassValidator(PassValidatorInterface $passValidator = null) |
209
|
|
|
{ |
210
|
|
|
$this->passValidator = $passValidator; |
211
|
|
|
|
212
|
|
|
return $this; |
213
|
|
|
} |
214
|
|
|
|
215
|
|
|
/** |
216
|
|
|
* @return PassValidatorInterface |
217
|
|
|
*/ |
218
|
|
|
public function getPassValidator() |
219
|
|
|
{ |
220
|
|
|
return $this->passValidator; |
221
|
|
|
} |
222
|
|
|
|
223
|
|
|
/** |
224
|
|
|
* Serialize pass |
225
|
|
|
* |
226
|
|
|
* @param PassInterface $pass |
227
|
|
|
* |
228
|
|
|
* @return string |
229
|
|
|
*/ |
230
|
|
|
public static function serialize(PassInterface $pass) |
231
|
|
|
{ |
232
|
|
|
return self::jsonEncode($pass->toArray()); |
233
|
|
|
} |
234
|
|
|
|
235
|
|
|
/** |
236
|
|
|
* Creates a pkpass file |
237
|
|
|
* |
238
|
|
|
* @param PassInterface $pass - the pass to be packaged into a .pkpass file |
239
|
|
|
* @param string $passName - filename to be used for the pass; if blank the serial number will be used |
240
|
|
|
* |
241
|
|
|
* @return SplFileObject If an IO error occurred |
242
|
|
|
* @throws Exception |
243
|
|
|
*/ |
244
|
|
|
public function package(PassInterface $pass, $passName = '') |
245
|
|
|
{ |
246
|
|
|
if ($pass->getSerialNumber() == '') { |
247
|
|
|
throw new \InvalidArgumentException('Pass must have a serial number to be packaged'); |
248
|
|
|
} |
249
|
|
|
|
250
|
|
|
$this->populateRequiredInformation($pass); |
251
|
|
|
|
252
|
|
|
if ($this->passValidator) { |
253
|
|
|
if (!$this->passValidator->validate($pass)){ |
254
|
|
|
throw new PassInvalidException($this->passValidator->getErrors()); |
255
|
|
|
}; |
256
|
|
|
} |
257
|
|
|
|
258
|
|
|
$passDir = $this->preparePassDirectory($pass); |
259
|
|
|
|
260
|
|
|
// Serialize pass |
261
|
|
|
file_put_contents($passDir . 'pass.json', self::serialize($pass)); |
262
|
|
|
|
263
|
|
|
// Images |
264
|
|
|
$this->prepareImages($pass, $passDir); |
265
|
|
|
|
266
|
|
|
// Localizations |
267
|
|
|
$this->prepareLocalizations($pass, $passDir); |
268
|
|
|
|
269
|
|
|
// Manifest.json - recursive, also add files in sub directories |
270
|
|
|
$manifestJSONFile = $this->prepareManifest($passDir); |
271
|
|
|
|
272
|
|
|
// Signature |
273
|
|
|
$this->sign($passDir, $manifestJSONFile); |
274
|
|
|
|
275
|
|
|
// Zip pass |
276
|
|
|
$zipFile = $this->getNormalizedOutputPath() . $this->getPassName($passName, $pass) . self::PASS_EXTENSION; |
277
|
|
|
$this->zip($passDir, $zipFile); |
278
|
|
|
|
279
|
|
|
// Remove temporary pass directory |
280
|
|
|
$this->rrmdir($passDir); |
281
|
|
|
|
282
|
|
|
return new SplFileObject($zipFile); |
283
|
|
|
} |
284
|
|
|
|
285
|
|
|
/** |
286
|
|
|
* @param $passDir |
287
|
|
|
* @param $manifestJSONFile |
288
|
|
|
*/ |
289
|
|
|
private function sign($passDir, $manifestJSONFile) |
290
|
|
|
{ |
291
|
|
|
if ($this->getSkipSignature()) { |
292
|
|
|
return; |
293
|
|
|
} |
294
|
|
|
|
295
|
|
|
$signatureFile = $passDir . 'signature'; |
296
|
|
|
$p12 = file_get_contents($this->p12->getRealPath()); |
297
|
|
|
$certs = array(); |
298
|
|
|
if (openssl_pkcs12_read($p12, $certs, $this->p12->getPassword()) == true) { |
|
|
|
|
299
|
|
|
$certdata = openssl_x509_read($certs['cert']); |
300
|
|
|
$privkey = openssl_pkey_get_private($certs['pkey'], $this->p12->getPassword()); |
301
|
|
|
openssl_pkcs7_sign( |
302
|
|
|
$manifestJSONFile, |
303
|
|
|
$signatureFile, |
304
|
|
|
$certdata, |
305
|
|
|
$privkey, |
306
|
|
|
array(), |
307
|
|
|
PKCS7_BINARY | PKCS7_DETACHED, |
308
|
|
|
$this->wwdr->getRealPath() |
309
|
|
|
); |
310
|
|
|
// Get signature content |
311
|
|
|
$signature = file_get_contents($signatureFile); |
312
|
|
|
// Check signature content |
313
|
|
|
if (!$signature) { |
314
|
|
|
throw new FileException("Couldn't read signature file."); |
315
|
|
|
} |
316
|
|
|
// Delimiters |
317
|
|
|
$begin = 'filename="smime.p7s"'; |
318
|
|
|
$end = '------'; |
319
|
|
|
// Convert signature |
320
|
|
|
$signature = substr($signature, strpos($signature, $begin) + strlen($begin)); |
321
|
|
|
$signature = substr($signature, 0, strpos($signature, $end)); |
322
|
|
|
$signature = base64_decode($signature); |
323
|
|
|
// Put new signature |
324
|
|
|
if (!file_put_contents($signatureFile, $signature)) { |
325
|
|
|
throw new FileException("Couldn't write signature file."); |
326
|
|
|
} |
327
|
|
|
} else { |
328
|
|
|
throw new FileException("Error reading certificate file"); |
329
|
|
|
} |
330
|
|
|
} |
331
|
|
|
|
332
|
|
|
/** |
333
|
|
|
* Creates a zip of a directory including all sub directories (recursive) |
334
|
|
|
* |
335
|
|
|
* @param $source - path to the source directory |
336
|
|
|
* @param $destination - output directory |
337
|
|
|
* |
338
|
|
|
* @return bool |
339
|
|
|
* @throws Exception |
340
|
|
|
*/ |
341
|
|
|
private function zip($source, $destination) |
|
|
|
|
342
|
|
|
{ |
343
|
|
|
if (!extension_loaded('zip')) { |
344
|
|
|
throw new Exception("ZIP extension not available"); |
345
|
|
|
} |
346
|
|
|
|
347
|
|
|
$source = realpath($source); |
|
|
|
|
348
|
|
|
if (!is_dir($source)) { |
349
|
|
|
throw new FileException("Source must be a directory."); |
350
|
|
|
} |
351
|
|
|
|
352
|
|
|
$zip = new ZipArchive(); |
353
|
|
|
$shouldOverwrite = $this->isOverwrite() ? ZipArchive::OVERWRITE : 0; |
354
|
|
|
if (!$zip->open($destination, ZipArchive::CREATE | $shouldOverwrite)) { |
355
|
|
|
throw new FileException("Couldn't open zip file."); |
356
|
|
|
} |
357
|
|
|
|
358
|
|
|
/* @var $iterator RecursiveIteratorIterator|RecursiveDirectoryIterator */ |
359
|
|
|
$dirIterator = new RecursiveDirectoryIterator($source, FilesystemIterator::SKIP_DOTS); |
360
|
|
|
$iterator = new RecursiveIteratorIterator($dirIterator, RecursiveIteratorIterator::SELF_FIRST); |
361
|
|
|
while ($iterator->valid()) { |
362
|
|
|
if ($iterator->isDir()) { |
363
|
|
|
$zip->addEmptyDir($iterator->getSubPathName()); |
364
|
|
|
} else if ($iterator->isFile()) { |
365
|
|
|
$zip->addFromString($iterator->getSubPathName(), file_get_contents($iterator->key())); |
366
|
|
|
} |
367
|
|
|
$iterator->next(); |
368
|
|
|
} |
369
|
|
|
|
370
|
|
|
return $zip->close(); |
371
|
|
|
} |
372
|
|
|
|
373
|
|
|
/** |
374
|
|
|
* Recursive folder remove |
375
|
|
|
* |
376
|
|
|
* @param string $dir |
377
|
|
|
* |
378
|
|
|
* @return bool |
379
|
|
|
*/ |
380
|
|
|
private function rrmdir($dir) |
|
|
|
|
381
|
|
|
{ |
382
|
|
|
$files = array_diff(scandir($dir), array('.', '..')); |
383
|
|
|
foreach ($files as $file) { |
384
|
|
|
is_dir("$dir/$file") ? $this->rrmdir("$dir/$file") : unlink("$dir/$file"); |
385
|
|
|
} |
386
|
|
|
|
387
|
|
|
return rmdir($dir); |
388
|
|
|
} |
389
|
|
|
|
390
|
|
|
/** |
391
|
|
|
* @param PassInterface $pass |
392
|
|
|
*/ |
393
|
|
|
private function populateRequiredInformation(PassInterface $pass) |
394
|
|
|
{ |
395
|
|
|
if (!$pass->getPassTypeIdentifier()) { |
396
|
|
|
$pass->setPassTypeIdentifier($this->passTypeIdentifier); |
397
|
|
|
} |
398
|
|
|
|
399
|
|
|
if (!$pass->getTeamIdentifier()) { |
400
|
|
|
$pass->setTeamIdentifier($this->teamIdentifier); |
401
|
|
|
} |
402
|
|
|
|
403
|
|
|
if (!$pass->getOrganizationName()) { |
404
|
|
|
$pass->setOrganizationName($this->organizationName); |
405
|
|
|
} |
406
|
|
|
} |
407
|
|
|
|
408
|
|
|
/** |
409
|
|
|
* @param $array |
410
|
|
|
* |
411
|
|
|
* @return string |
412
|
|
|
*/ |
413
|
|
|
private static function jsonEncode($array) |
414
|
|
|
{ |
415
|
|
|
// Check if JSON_UNESCAPED_SLASHES is defined to support PHP 5.3. |
416
|
|
|
$options = defined('JSON_UNESCAPED_SLASHES') ? JSON_UNESCAPED_SLASHES : 0; |
417
|
|
|
return json_encode($array, $options); |
418
|
|
|
} |
419
|
|
|
|
420
|
|
|
/** |
421
|
|
|
* @param $passName |
422
|
|
|
* @param PassInterface $pass |
423
|
|
|
* |
424
|
|
|
* @return string |
425
|
|
|
*/ |
426
|
|
|
public function getPassName($passName, PassInterface $pass) |
427
|
|
|
{ |
428
|
|
|
$passNameSanitised = preg_replace("/[^a-zA-Z0-9]+/", "", $passName); |
429
|
|
|
return strlen($passNameSanitised) != 0 ? $passNameSanitised : $pass->getSerialNumber(); |
430
|
|
|
} |
431
|
|
|
|
432
|
|
|
/** |
433
|
|
|
* @param $passDir |
434
|
|
|
* |
435
|
|
|
* @return string |
436
|
|
|
*/ |
437
|
|
|
private function prepareManifest($passDir) |
438
|
|
|
{ |
439
|
|
|
$manifestJSONFile = $passDir . 'manifest.json'; |
440
|
|
|
$manifest = array(); |
441
|
|
|
$files = new RecursiveIteratorIterator( |
442
|
|
|
new RecursiveDirectoryIterator($passDir), |
443
|
|
|
RecursiveIteratorIterator::SELF_FIRST |
444
|
|
|
); |
445
|
|
|
foreach ($files as $file) { |
446
|
|
|
// Ignore "." and ".." folders |
447
|
|
|
if (in_array(substr($file, strrpos($file, '/') + 1), array('.', '..'))) { |
448
|
|
|
continue; |
449
|
|
|
} |
450
|
|
|
// |
451
|
|
|
$filePath = realpath($file); |
452
|
|
|
if (is_file($filePath) === true) { |
453
|
|
|
$relativePathName = str_replace($passDir, '', $file->getPathname()); |
454
|
|
|
$manifest[$relativePathName] = sha1_file($filePath); |
455
|
|
|
} |
456
|
|
|
} |
457
|
|
|
file_put_contents($manifestJSONFile, $this->jsonEncode($manifest)); |
458
|
|
|
|
459
|
|
|
return $manifestJSONFile; |
460
|
|
|
} |
461
|
|
|
|
462
|
|
|
/** |
463
|
|
|
* @param PassInterface $pass |
464
|
|
|
* |
465
|
|
|
* @return string |
466
|
|
|
*/ |
467
|
|
|
private function preparePassDirectory(PassInterface $pass) |
468
|
|
|
{ |
469
|
|
|
$passDir = $this->getNormalizedOutputPath() . $pass->getSerialNumber() . DIRECTORY_SEPARATOR; |
470
|
|
|
$passDirExists = file_exists($passDir); |
471
|
|
|
if ($passDirExists && !$this->isOverwrite()) { |
472
|
|
|
throw new FileException("Temporary pass directory already exists"); |
473
|
|
|
} elseif (!$passDirExists && !mkdir($passDir, 0777, true)) { |
474
|
|
|
throw new FileException("Couldn't create temporary pass directory"); |
475
|
|
|
} |
476
|
|
|
|
477
|
|
|
return $passDir; |
478
|
|
|
} |
479
|
|
|
|
480
|
|
|
/** |
481
|
|
|
* @param PassInterface $pass |
482
|
|
|
* @param $passDir |
483
|
|
|
*/ |
484
|
|
|
private function prepareImages(PassInterface $pass, $passDir) |
485
|
|
|
{ |
486
|
|
|
/** @var Image $image */ |
487
|
|
|
foreach ($pass->getImages() as $image) { |
488
|
|
|
$fileName = $passDir . $image->getContext(); |
489
|
|
|
if ($image->isRetina()) { |
490
|
|
|
$fileName .= '@2x'; |
491
|
|
|
} |
492
|
|
|
$fileName .= '.' . $image->getExtension(); |
493
|
|
|
copy($image->getPathname(), $fileName); |
494
|
|
|
} |
495
|
|
|
} |
496
|
|
|
|
497
|
|
|
/** |
498
|
|
|
* @param PassInterface $pass |
499
|
|
|
* @param $passDir |
500
|
|
|
*/ |
501
|
|
|
private function prepareLocalizations(PassInterface $pass, $passDir) |
502
|
|
|
{ |
503
|
|
|
foreach ($pass->getLocalizations() as $localization) { |
504
|
|
|
// Create dir (LANGUAGE.lproj) |
505
|
|
|
$localizationDir = $passDir . $localization->getLanguage() . '.lproj' . DIRECTORY_SEPARATOR; |
506
|
|
|
mkdir($localizationDir, 0777, true); |
507
|
|
|
|
508
|
|
|
// pass.strings File (Format: "token" = "value") |
509
|
|
|
$localizationStringsFile = $localizationDir . 'pass.strings'; |
510
|
|
|
file_put_contents($localizationStringsFile, $localization->getStringsFileOutput()); |
511
|
|
|
|
512
|
|
|
// Localization images |
513
|
|
|
foreach ($localization->getImages() as $image) { |
514
|
|
|
$fileName = $localizationDir . $image->getContext(); |
515
|
|
|
if ($image->isRetina()) { |
516
|
|
|
$fileName .= '@2x'; |
517
|
|
|
} |
518
|
|
|
$fileName .= '.' . $image->getExtension(); |
519
|
|
|
copy($image->getPathname(), $fileName); |
520
|
|
|
} |
521
|
|
|
} |
522
|
|
|
} |
523
|
|
|
|
524
|
|
|
|
525
|
|
|
} |
526
|
|
|
|
This check examines a number of code elements and verifies that they conform to the given naming conventions.
You can set conventions for local variables, abstract classes, utility classes, constant, properties, methods, parameters, interfaces, classes, exceptions and special methods.