PassFactory::setOutputPath()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 2
Bugs 1 Features 0
Metric Value
eloc 2
c 2
b 1
f 0
dl 0
loc 5
ccs 3
cts 3
cp 1
rs 10
cc 1
nc 1
nop 1
crap 1
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 InvalidArgumentException;
17
use Passbook\Certificate\P12;
18
use Passbook\Certificate\WWDR;
19
use Passbook\Exception\FileException;
20
use Passbook\Exception\PassInvalidException;
21
use Passbook\Pass\Image;
22
use RecursiveDirectoryIterator;
23
use RecursiveIteratorIterator;
24
use SplFileObject;
25
use ZipArchive;
26
27
/**
28
 * PassFactory - Creates .pkpass files
29
 *
30
 * @author Eymen Gunay <[email protected]>
31
 */
32
class PassFactory
33
{
34
    /**
35
     * Output path for generated pass files
36
     *
37
     * @var string
38
     */
39
    protected $outputPath = '';
40
41
    /**
42
     * Overwrite if pass exists
43
     *
44
     * @var bool
45
     */
46
    protected $overwrite = false;
47
48
    /**
49
     * Pass type identifier
50
     *
51
     * @var string
52
     */
53
    protected $passTypeIdentifier;
54
55
    /**
56
     * Team identifier
57
     *
58
     * @var string
59
     */
60
    protected $teamIdentifier;
61
62
    /**
63
     * Organization name
64
     *
65
     * @var string
66
     */
67
    protected $organizationName;
68
69
    /**
70
     * P12 file
71
     *
72
     * @var \Passbook\Certificate\P12Interface
73
     */
74
    protected $p12;
75
76
    /**
77
     * WWDR file
78
     *
79
     * @var \Passbook\Certificate\WWDRInterface
80
     */
81
    protected $wwdr;
82
83
    /**
84
     * @var bool - skip signing the pass; should only be used for testing
85
     */
86
    protected $skipSignature;
87
88
    /**
89
     * @var PassValidatorInterface
90
     */
91
    private $passValidator;
92
93
    /**
94
     * Pass file extension
95
     *
96
     * @var string
97
     */
98
    public const PASS_EXTENSION = '.pkpass';
99
100 7
    public function __construct($passTypeIdentifier, $teamIdentifier, $organizationName, $p12File, $p12Pass, $wwdrFile)
101
    {
102
        // Required pass information
103 7
        $this->passTypeIdentifier = $passTypeIdentifier;
104 7
        $this->teamIdentifier = $teamIdentifier;
105 7
        $this->organizationName = $organizationName;
106
107
        // Create certificate objects
108 7
        $this->p12 = new P12($p12File, $p12Pass);
109 7
        $this->wwdr = new WWDR($wwdrFile);
110
111
        // By default use the PassValidator
112 7
        $this->passValidator = new PassValidator();
113
    }
114
115
    /**
116
     * Set outputPath
117
     *
118
     * @param string $outputPath
119
     *
120
     * @return $this
121
     */
122 6
    public function setOutputPath($outputPath)
123
    {
124 6
        $this->outputPath = $outputPath;
125
126 6
        return $this;
127
    }
128
129
    /**
130
     * Get outputPath
131
     *
132
     * @return string
133
     */
134 1
    public function getOutputPath()
135
    {
136 1
        return $this->outputPath;
137
    }
138
139
    /**
140
     * The output path with a directory separator on the end.
141
     *
142
     * @return string
143
     */
144 4
    public function getNormalizedOutputPath()
145
    {
146 4
        return rtrim($this->outputPath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
147
    }
148
149
    /**
150
     * Set overwrite
151
     *
152
     * @param boolean $overwrite
153
     *
154
     * @return $this
155
     */
156 2
    public function setOverwrite($overwrite)
157
    {
158 2
        $this->overwrite = $overwrite;
159
160 2
        return $this;
161
    }
162
163
    /**
164
     * Get overwrite
165
     *
166
     * @return boolean
167
     */
168 4
    public function isOverwrite()
169
    {
170 4
        return $this->overwrite;
171
    }
172
173
    /**
174
     * Set skip signature
175
     *
176
     * When set, the pass will not be signed when packaged. This should only
177
     * be used for testing.
178
     *
179
     * @param boolean $skipSignature
180
     *
181
     * @return $this
182
     */
183 2
    public function setSkipSignature($skipSignature)
184
    {
185 2
        $this->skipSignature = $skipSignature;
186
187 2
        return $this;
188
    }
189
190
    /**
191
     * Get skip signature
192
     *
193
     * @return boolean
194
     */
195 3
    public function getSkipSignature()
196
    {
197 3
        return $this->skipSignature;
198
    }
199
200
    /**
201
     * Set an implementation of PassValidatorInterface to validate the pass
202
     * before packaging. When set to null, no validation is performed when
203
     * packaging the pass.
204
     *
205
     * @param PassValidatorInterface|null $passValidator
206
     *
207
     * @return $this
208
     */
209 1
    public function setPassValidator(PassValidatorInterface $passValidator = null)
210
    {
211 1
        $this->passValidator = $passValidator;
212
213 1
        return $this;
214
    }
215
216
    /**
217
     * @return PassValidatorInterface
218
     */
219
    public function getPassValidator()
220
    {
221
        return $this->passValidator;
222
    }
223
224
    /**
225
     * Serialize pass
226
     *
227
     * @param  PassInterface $pass
228
     *
229
     * @return string
230
     */
231 9
    public static function serialize(PassInterface $pass)
232
    {
233 9
        return self::jsonEncode($pass->toArray());
234
    }
235
236
    /**
237
     * Creates a pkpass file
238
     *
239
     * @param  PassInterface $pass - the pass to be packaged into a .pkpass file
240
     * @param string $passName - filename to be used for the pass; if blank the serial number will be used
241
     *
242
     * @return SplFileObject If an IO error occurred
243
     * @throws InvalidArgumentException|PassInvalidException|Exception
244
     */
245 5
    public function package(PassInterface $pass, $passName = '')
246
    {
247 5
        if ($pass->getSerialNumber() == '') {
248 1
            throw new InvalidArgumentException('Pass must have a serial number to be packaged');
249
        }
250
251 4
        $this->populateRequiredInformation($pass);
252
253 4
        if ($this->passValidator) {
254 4
            if (!$this->passValidator->validate($pass)) {
255 1
                throw new PassInvalidException('Failed to validate passbook', $this->passValidator->getErrors());
256
            };
257
        }
258
259 3
        $passDir = $this->preparePassDirectory($pass);
260
261
        // Serialize pass
262 3
        file_put_contents($passDir . 'pass.json', self::serialize($pass));
263
264
        // Images
265 3
        $this->prepareImages($pass, $passDir);
266
267
        // Localizations
268 3
        $this->prepareLocalizations($pass, $passDir);
269
270
        // Manifest.json - recursive, also add files in sub directories
271 3
        $manifestJSONFile = $this->prepareManifest($passDir);
272
273
        // Signature
274 3
        $this->sign($passDir, $manifestJSONFile);
275
276
        // Zip pass
277 3
        $zipFile = $this->getNormalizedOutputPath() . $this->getPassName($passName, $pass) . self::PASS_EXTENSION;
278 3
        $this->zip($passDir, $zipFile);
279
280
        // Remove temporary pass directory
281 3
        $this->rrmdir($passDir);
282
283 3
        return new SplFileObject($zipFile);
284
    }
285
286
    /**
287
     * @param $passDir
288
     * @param $manifestJSONFile
289
     */
290 3
    private function sign($passDir, $manifestJSONFile): void
291
    {
292 3
        if ($this->getSkipSignature()) {
293 2
            return;
294
        }
295
296 1
        $signatureFile = $passDir . 'signature';
297 1
        $p12 = file_get_contents($this->p12->getRealPath());
298 1
        $certs = [];
299 1
        if (openssl_pkcs12_read($p12, $certs, $this->p12->getPassword()) === true) {
300 1
            $certdata = openssl_x509_read($certs['cert']);
301 1
            $privkey = openssl_pkey_get_private($certs['pkey'], $this->p12->getPassword());
302 1
            openssl_pkcs7_sign(
303 1
                $manifestJSONFile,
304 1
                $signatureFile,
305 1
                $certdata,
306 1
                $privkey,
307 1
                [],
308 1
                PKCS7_BINARY | PKCS7_DETACHED,
309 1
                $this->wwdr->getRealPath()
310 1
            );
311
            // Get signature content
312 1
            $signature = file_get_contents($signatureFile);
313
            // Check signature content
314 1
            if (!$signature) {
315
                throw new FileException("Couldn't read signature file.");
316
            }
317
            // Delimiters
318 1
            $begin = 'filename="smime.p7s"';
319 1
            $end = '------';
320
            // Convert signature
321 1
            $signature = substr($signature, strpos($signature, $begin) + strlen($begin));
322 1
            $signature = substr($signature, 0, strpos($signature, $end));
323 1
            $signature = base64_decode($signature);
324
            // Put new signature
325 1
            if (!file_put_contents($signatureFile, $signature)) {
326 1
                throw new FileException("Couldn't write signature file.");
327
            }
328
        } else {
329
            throw new FileException('Error reading certificate file');
330
        }
331
    }
332
333
    /**
334
     * Creates a zip of a directory including all sub directories (recursive)
335
     *
336
     * @param $source - path to the source directory
0 ignored issues
show
Documentation Bug introduced by
The doc comment - at position 0 could not be parsed: Unknown type name '-' at position 0 in -.
Loading history...
337
     * @param $destination - output directory
338
     *
339
     * @return bool
340
     * @throws Exception
341
     */
342 3
    private function zip($source, $destination)
343
    {
344 3
        if (!extension_loaded('zip')) {
345
            throw new Exception('ZIP extension not available');
346
        }
347
348 3
        $source = realpath($source);
349 3
        if (!is_dir($source)) {
350
            throw new FileException('Source must be a directory.');
351
        }
352
353 3
        $zip = new ZipArchive();
354 3
        $shouldOverwrite = $this->isOverwrite() ? ZipArchive::OVERWRITE : 0;
355 3
        if (!$zip->open($destination, ZipArchive::CREATE | $shouldOverwrite)) {
356
            throw new FileException("Couldn't open zip file.");
357
        }
358
359
        /* @var $iterator RecursiveIteratorIterator|RecursiveDirectoryIterator */
360 3
        $dirIterator = new RecursiveDirectoryIterator($source, FilesystemIterator::SKIP_DOTS);
361 3
        $iterator = new RecursiveIteratorIterator($dirIterator, RecursiveIteratorIterator::SELF_FIRST);
362 3
        while ($iterator->valid()) {
363 3
            if ($iterator->isDir()) {
0 ignored issues
show
Bug introduced by
The method isDir() does not exist on RecursiveIteratorIterator. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

363
            if ($iterator->/** @scrutinizer ignore-call */ isDir()) {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
364 1
                $zip->addEmptyDir($iterator->getSubPathName());
0 ignored issues
show
Bug introduced by
The method getSubPathName() does not exist on RecursiveIteratorIterator. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

364
                $zip->addEmptyDir($iterator->/** @scrutinizer ignore-call */ getSubPathName());

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
365 3
            } elseif ($iterator->isFile()) {
0 ignored issues
show
Bug introduced by
The method isFile() does not exist on RecursiveIteratorIterator. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

365
            } elseif ($iterator->/** @scrutinizer ignore-call */ isFile()) {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
366 3
                $zip->addFromString($iterator->getSubPathName(), file_get_contents($iterator->key()));
0 ignored issues
show
Bug introduced by
It seems like $iterator->key() can also be of type null and true; however, parameter $filename of file_get_contents() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

366
                $zip->addFromString($iterator->getSubPathName(), file_get_contents(/** @scrutinizer ignore-type */ $iterator->key()));
Loading history...
367
            }
368 3
            $iterator->next();
369
        }
370
371 3
        return $zip->close();
372
    }
373
374
    /**
375
     * Recursive folder remove
376
     *
377
     * @param string $dir
378
     *
379
     * @return bool
380
     */
381 3
    private function rrmdir($dir)
382
    {
383 3
        $files = array_diff(scandir($dir), ['.', '..']);
384 3
        foreach ($files as $file) {
385 3
            is_dir("$dir/$file") ? $this->rrmdir("$dir/$file") : unlink("$dir/$file");
386
        }
387
388 3
        return rmdir($dir);
389
    }
390
391
    /**
392
     * @param PassInterface $pass
393
     */
394 4
    private function populateRequiredInformation(PassInterface $pass): void
395
    {
396 4
        if (!$pass->getPassTypeIdentifier()) {
397 3
            $pass->setPassTypeIdentifier($this->passTypeIdentifier);
398
        }
399
400 4
        if (!$pass->getTeamIdentifier()) {
401 3
            $pass->setTeamIdentifier($this->teamIdentifier);
402
        }
403
404 4
        if (!$pass->getOrganizationName()) {
405 3
            $pass->setOrganizationName($this->organizationName);
406
        }
407
    }
408
409
    /**
410
     * @param $array
411
     *
412
     * @return string
413
     */
414 9
    private static function jsonEncode($array)
415
    {
416
        // Check if JSON_UNESCAPED_SLASHES is defined to support PHP 5.3.
417 9
        $options = defined('JSON_UNESCAPED_SLASHES') ? JSON_UNESCAPED_SLASHES : 0;
418 9
        return json_encode($array, $options);
419
    }
420
421
    /**
422
     * @param $passName
423
     * @param PassInterface $pass
424
     *
425
     * @return string
426
     */
427 3
    public function getPassName($passName, PassInterface $pass)
428
    {
429 3
        $passNameSanitised = preg_replace('/[^a-zA-Z0-9]+/', '', $passName);
430 3
        return strlen($passNameSanitised) != 0 ? $passNameSanitised : $pass->getSerialNumber();
431
    }
432
433
    /**
434
     * @param $passDir
435
     *
436
     * @return string
437
     */
438 3
    private function prepareManifest($passDir)
439
    {
440 3
        $manifestJSONFile = $passDir . 'manifest.json';
441 3
        $manifest = [];
442 3
        $files = new RecursiveIteratorIterator(
443 3
            new RecursiveDirectoryIterator($passDir),
444 3
            RecursiveIteratorIterator::SELF_FIRST
445 3
        );
446 3
        foreach ($files as $file) {
447
            // Ignore "." and ".." folders
448 3
            if (in_array(substr($file, strrpos($file, '/') + 1), ['.', '..'])) {
449 3
                continue;
450
            }
451
            //
452 3
            $filePath = realpath($file);
453 3
            if (is_file($filePath) === true) {
454 3
                $relativePathName = str_replace($passDir, '', $file->getPathname());
455 3
                $manifest[$relativePathName] = sha1_file($filePath);
456
            }
457
        }
458 3
        file_put_contents($manifestJSONFile, $this->jsonEncode($manifest));
459
460 3
        return $manifestJSONFile;
461
    }
462
463
    /**
464
     * @param PassInterface $pass
465
     *
466
     * @return string
467
     */
468 3
    private function preparePassDirectory(PassInterface $pass)
469
    {
470 3
        $passDir = $this->getNormalizedOutputPath() . $pass->getSerialNumber() . DIRECTORY_SEPARATOR;
471 3
        $passDirExists = file_exists($passDir);
472 3
        if ($passDirExists && !$this->isOverwrite()) {
473
            throw new FileException('Temporary pass directory already exists');
474 3
        } elseif (!$passDirExists && !mkdir($passDir, 0777, true)) {
475
            throw new FileException("Couldn't create temporary pass directory");
476
        }
477
478 3
        return $passDir;
479
    }
480
481
    /**
482
     * @param PassInterface $pass
483
     * @param string        $passDir
484
     */
485 3
    private function prepareImages(PassInterface $pass, $passDir): void
486
    {
487
        /** @var Image $image */
488 3
        foreach ($pass->getImages() as $image) {
489 3
            $fileName = $passDir . $image->getContext();
490 3
            if ($image->getDensity() === 2) {
491
                $fileName .= '@2x';
492 3
            } elseif ($image->getDensity() === 3) {
493
                $fileName .= '@3x';
494
            }
495
496 3
            $fileName .= '.' . $image->getExtension();
497 3
            copy($image->getPathname(), $fileName);
498
        }
499
    }
500
501
    /**
502
     * @param PassInterface $pass
503
     * @param string        $passDir
504
     */
505 3
    private function prepareLocalizations(PassInterface $pass, $passDir): void
506
    {
507 3
        foreach ($pass->getLocalizations() as $localization) {
508
            // Create dir (LANGUAGE.lproj)
509 1
            $localizationDir = $passDir . $localization->getLanguage() . '.lproj' . DIRECTORY_SEPARATOR;
510 1
            $localizationDirExists = file_exists($localizationDir);
511 1
            if ($localizationDirExists && !$this->isOverwrite()) {
512
                throw new FileException("Temporary pass localization directory already exists ({$localization->getLanguage()})");
513 1
            } elseif (!$localizationDirExists && !mkdir($localizationDir, 0777, true)) {
514
                throw new FileException("Couldn't create temporary pass localization directory ({$localization->getLanguage()})");
515
            }
516
517
            // pass.strings File (Format: "token" = "value")
518 1
            $localizationStringsFile = $localizationDir . 'pass.strings';
519 1
            file_put_contents($localizationStringsFile, $localization->getStringsFileOutput());
520
521
            // Localization images
522 1
            foreach ($localization->getImages() as $image) {
523
                $fileName = $localizationDir . $image->getContext();
524
                if ($image->getDensity() === 2) {
525
                    $fileName .= '@2x';
526
                } elseif ($image->getDensity() === 3) {
527
                    $fileName .= '@3x';
528
                }
529
                $fileName .= '.' . $image->getExtension();
530
                copy($image->getPathname(), $fileName);
531
            }
532
        }
533
    }
534
}
535