XmlExportService::makeXML()   A
last analyzed

Complexity

Conditions 3
Paths 17

Size

Total Lines 20
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 18
c 1
b 0
f 0
nc 17
nop 0
dl 0
loc 20
rs 9.6666
1
<?php
2
/**
3
 * sysPass
4
 *
5
 * @author    nuxsmin
6
 * @link      https://syspass.org
7
 * @copyright 2012-2019, Rubén Domínguez nuxsmin@$syspass.org
8
 *
9
 * This file is part of sysPass.
10
 *
11
 * sysPass is free software: you can redistribute it and/or modify
12
 * it under the terms of the GNU General Public License as published by
13
 * the Free Software Foundation, either version 3 of the License, or
14
 * (at your option) any later version.
15
 *
16
 * sysPass is distributed in the hope that it will be useful,
17
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
18
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
19
 * GNU General Public License for more details.
20
 *
21
 * You should have received a copy of the GNU General Public License
22
 *  along with sysPass.  If not, see <http://www.gnu.org/licenses/>.
23
 */
24
25
namespace SP\Services\Export;
26
27
use DOMDocument;
28
use DOMElement;
29
use DOMXPath;
30
use Exception;
31
use Psr\Container\ContainerExceptionInterface;
32
use Psr\Container\NotFoundExceptionInterface;
33
use SP\Config\ConfigData;
34
use SP\Core\AppInfoInterface;
35
use SP\Core\Crypt\Crypt;
36
use SP\Core\Crypt\Hash;
37
use SP\Core\Events\Event;
38
use SP\Core\Events\EventMessage;
39
use SP\Core\Exceptions\CheckException;
40
use SP\Core\PhpExtensionChecker;
41
use SP\DataModel\CategoryData;
42
use SP\Services\Account\AccountService;
43
use SP\Services\Account\AccountToTagService;
44
use SP\Services\Category\CategoryService;
45
use SP\Services\Client\ClientService;
46
use SP\Services\Service;
47
use SP\Services\ServiceException;
48
use SP\Services\Tag\TagService;
49
use SP\Storage\File\ArchiveHandler;
50
use SP\Storage\File\FileException;
51
use SP\Storage\File\FileHandler;
52
use SP\Util\VersionUtil;
53
54
defined('APP_ROOT') || die();
55
56
/**
57
 * Clase XmlExport para realizar la exportación de las cuentas de sysPass a formato XML
58
 *
59
 * @package SP
60
 */
61
final class XmlExportService extends Service
62
{
63
    /**
64
     * @var ConfigData
65
     */
66
    private $configData;
67
    /**
68
     * @var
69
     */
70
    private $extensionChecker;
71
    /**
72
     * @var DOMDocument
73
     */
74
    private $xml;
75
    /**
76
     * @var DOMElement
77
     */
78
    private $root;
79
    /**
80
     * @var string
81
     */
82
    private $exportPass;
83
    /**
84
     * @var bool
85
     */
86
    private $encrypted = false;
87
    /**
88
     * @var string
89
     */
90
    private $exportPath;
91
    /**
92
     * @var string
93
     */
94
    private $exportFile;
95
96
    /**
97
     * Realiza la exportación de las cuentas a XML
98
     *
99
     * @param string $exportPath
100
     * @param string $pass string La clave de exportación
101
     *
102
     * @throws ServiceException
103
     * @throws FileException
104
     */
105
    public function doExport(string $exportPath, string $pass = null)
106
    {
107
        set_time_limit(0);
108
109
        if (!empty($pass)) {
110
            $this->exportPass = $pass;
111
            $this->encrypted = true;
112
        }
113
114
        $this->setExportPath($exportPath);
115
        $this->exportFile = $this->generateExportFilename();
116
        $this->deleteOldExports();
117
        $this->makeXML();
118
    }
119
120
    /**
121
     * @param string $exportPath
122
     *
123
     * @throws ServiceException
124
     */
125
    private function setExportPath(string $exportPath)
126
    {
127
        if (!is_dir($exportPath)
128
            && @mkdir($exportPath, 0700, true) === false
129
        ) {
130
            throw new ServiceException(sprintf(__('Unable to create the directory (%s)'), $exportPath));
131
        }
132
133
        $this->exportPath = $exportPath;
134
    }
135
136
    /**
137
     * Genera el nombre del archivo usado para la exportación.
138
     *
139
     * @return string
140
     * @throws FileException
141
     */
142
    private function generateExportFilename(): string
143
    {
144
        // Generar hash unico para evitar descargas no permitidas
145
        $hash = sha1(uniqid('sysPassExport', true));
146
        $this->configData->setExportHash($hash);
147
        $this->config->saveConfig($this->configData);
148
149
        return self::getExportFilename($this->exportPath, $hash);
150
    }
151
152
    /**
153
     * @param string $path
154
     * @param string $hash
155
     * @param bool   $compressed
156
     *
157
     * @return string
158
     */
159
    public static function getExportFilename(string $path, string $hash, bool $compressed = false)
160
    {
161
        $file = $path . DIRECTORY_SEPARATOR . AppInfoInterface::APP_NAME . '_export-' . $hash;
162
163
        if ($compressed) {
164
            return $file . ArchiveHandler::COMPRESS_EXTENSION;
165
        }
166
167
        return $file . '.xml';
168
    }
169
170
    /**
171
     * Eliminar los archivos de exportación anteriores
172
     */
173
    private function deleteOldExports()
174
    {
175
        $path = $this->exportPath . DIRECTORY_SEPARATOR . AppInfoInterface::APP_NAME;
176
177
        array_map(function ($file) {
178
            return @unlink($file);
179
        }, array_merge(glob($path . '_export-*'), glob($path . '*.xml')));
0 ignored issues
show
Bug introduced by
It seems like glob($path . '*.xml') can also be of type false; however, parameter $array2 of array_merge() does only seem to accept array|null, 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

179
        }, array_merge(glob($path . '_export-*'), /** @scrutinizer ignore-type */ glob($path . '*.xml')));
Loading history...
Bug introduced by
It seems like glob($path . '_export-*') can also be of type false; however, parameter $array1 of array_merge() does only seem to accept array, 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

179
        }, array_merge(/** @scrutinizer ignore-type */ glob($path . '_export-*'), glob($path . '*.xml')));
Loading history...
180
    }
181
182
    /**
183
     * Crear el documento XML y guardarlo
184
     *
185
     * @throws ContainerExceptionInterface
186
     * @throws NotFoundExceptionInterface
187
     * @throws ServiceException
188
     */
189
    private function makeXML()
190
    {
191
        try {
192
            $this->createRoot();
193
            $this->createMeta();
194
            $this->createCategories();
195
            $this->createClients();
196
            $this->createTags();
197
            $this->createAccounts();
198
            $this->createHash();
199
            $this->writeXML();
200
        } catch (ServiceException $e) {
201
            throw $e;
202
        } catch (Exception $e) {
203
            throw new ServiceException(
204
                __u('Error while exporting'),
205
                ServiceException::ERROR,
206
                __u('Please check out the event log for more details'),
207
                $e->getCode(),
208
                $e
209
            );
210
        }
211
    }
212
213
    /**
214
     * Crear el nodo raíz
215
     *
216
     * @throws ServiceException
217
     */
218
    private function createRoot()
219
    {
220
        try {
221
            $this->xml = new DOMDocument('1.0', 'UTF-8');
222
            $this->root = $this->xml->appendChild($this->xml->createElement('Root'));
223
        } catch (Exception $e) {
224
            throw new ServiceException($e->getMessage(), ServiceException::ERROR, __FUNCTION__);
225
        }
226
    }
227
228
    /**
229
     * Crear el nodo con metainformación del archivo XML
230
     *
231
     * @throws ServiceException
232
     */
233
    private function createMeta()
234
    {
235
        try {
236
            $userData = $this->context->getUserData();
237
238
            $nodeMeta = $this->xml->createElement('Meta');
239
            $metaGenerator = $this->xml->createElement('Generator', 'sysPass');
240
            $metaVersion = $this->xml->createElement('Version', VersionUtil::getVersionStringNormalized());
241
            $metaTime = $this->xml->createElement('Time', time());
242
            $metaUser = $this->xml->createElement('User', $userData->getLogin());
243
            $metaUser->setAttribute('id', $userData->getId());
244
            $metaGroup = $this->xml->createElement('Group', $userData->getUserGroupName());
245
            $metaGroup->setAttribute('id', $userData->getUserGroupId());
246
247
            $nodeMeta->appendChild($metaGenerator);
248
            $nodeMeta->appendChild($metaVersion);
249
            $nodeMeta->appendChild($metaTime);
250
            $nodeMeta->appendChild($metaUser);
251
            $nodeMeta->appendChild($metaGroup);
252
253
            $this->root->appendChild($nodeMeta);
254
        } catch (Exception $e) {
255
            throw new ServiceException($e->getMessage(), ServiceException::ERROR, __FUNCTION__);
256
        }
257
    }
258
259
    /**
260
     * Crear el nodo con los datos de las categorías
261
     *
262
     * @throws ContainerExceptionInterface
263
     * @throws NotFoundExceptionInterface
264
     * @throws ServiceException
265
     */
266
    private function createCategories()
267
    {
268
        try {
269
            $this->eventDispatcher->notifyEvent('run.export.process.category',
270
                new Event($this, EventMessage::factory()
271
                    ->addDescription(__u('Exporting categories')))
272
            );
273
274
            $categoryService = $this->dic->get(CategoryService::class);
275
            $categories = $categoryService->getAllBasic();
276
277
            // Crear el nodo de categorías
278
            $nodeCategories = $this->xml->createElement('Categories');
279
280
            if (count($categories) === 0) {
281
                $this->appendNode($nodeCategories);
282
283
                return;
284
            }
285
286
            foreach ($categories as $category) {
287
                /** @var $category CategoryData */
288
                $categoryName = $this->xml->createElement('name', $this->escapeChars($category->getName()));
289
                $categoryDescription = $this->xml->createElement('description', $this->escapeChars($category->getDescription()));
290
291
                // Crear el nodo de categoría
292
                $nodeCategory = $this->xml->createElement('Category');
293
                $nodeCategory->setAttribute('id', $category->getId());
294
                $nodeCategory->appendChild($categoryName);
295
                $nodeCategory->appendChild($categoryDescription);
296
297
                // Añadir categoría al nodo de categorías
298
                $nodeCategories->appendChild($nodeCategory);
299
            }
300
301
            $this->appendNode($nodeCategories);
302
        } catch (Exception $e) {
303
            throw new ServiceException($e->getMessage(), ServiceException::ERROR, __FUNCTION__);
304
        }
305
    }
306
307
    /**
308
     * Añadir un nuevo nodo al árbol raíz
309
     *
310
     * @param DOMElement $node El nodo a añadir
311
     *
312
     * @throws ServiceException
313
     */
314
    private function appendNode(DOMElement $node)
315
    {
316
        try {
317
            // Si se utiliza clave de encriptación los datos se encriptan en un nuevo nodo:
318
            // Encrypted -> Data
319
            if ($this->encrypted === true) {
320
                // Obtener el nodo en formato XML
321
                $nodeXML = $this->xml->saveXML($node);
322
323
                // Crear los datos encriptados con la información del nodo
324
                $securedKey = Crypt::makeSecuredKey($this->exportPass);
325
                $encrypted = Crypt::encrypt($nodeXML, $securedKey, $this->exportPass);
326
327
                // Buscar si existe ya un nodo para el conjunto de datos encriptados
328
                $encryptedNode = $this->root->getElementsByTagName('Encrypted')->item(0);
329
330
                if (!$encryptedNode instanceof DOMElement) {
331
                    $encryptedNode = $this->xml->createElement('Encrypted');
332
                    $encryptedNode->setAttribute('hash', Hash::hashKey($this->exportPass));
333
                }
334
335
                // Crear el nodo hijo con los datos encriptados
336
                $encryptedData = $this->xml->createElement('Data', base64_encode($encrypted));
337
338
                $encryptedDataKey = $this->xml->createAttribute('key');
339
                $encryptedDataKey->value = $securedKey;
340
341
                // Añadir nodos de datos
342
                $encryptedData->appendChild($encryptedDataKey);
343
                $encryptedNode->appendChild($encryptedData);
344
345
                // Añadir el nodo encriptado
346
                $this->root->appendChild($encryptedNode);
347
            } else {
348
                $this->root->appendChild($node);
349
            }
350
        } catch (Exception $e) {
351
            throw new ServiceException($e->getMessage(), ServiceException::ERROR, __FUNCTION__);
352
        }
353
    }
354
355
    /**
356
     * Escapar carácteres no válidos en XML
357
     *
358
     * @param $data string Los datos a escapar
359
     *
360
     * @return mixed
361
     */
362
    private function escapeChars($data)
363
    {
364
        $arrStrFrom = ['&', '<', '>', '"', '\''];
365
        $arrStrTo = ['&#38;', '&#60;', '&#62;', '&#34;', '&#39;'];
366
367
        return str_replace($arrStrFrom, $arrStrTo, $data);
368
    }
369
370
    /**
371
     * Crear el nodo con los datos de los clientes
372
     *
373
     * @throws ServiceException
374
     * @throws ServiceException
375
     * @throws ContainerExceptionInterface
376
     * @throws NotFoundExceptionInterface
377
     */
378
    private function createClients()
379
    {
380
        try {
381
            $this->eventDispatcher->notifyEvent('run.export.process.client',
382
                new Event($this, EventMessage::factory()
383
                    ->addDescription(__u('Exporting clients')))
384
            );
385
386
            $clientService = $this->dic->get(ClientService::class);
387
            $clients = $clientService->getAllBasic();
388
389
            // Crear el nodo de clientes
390
            $nodeClients = $this->xml->createElement('Clients');
391
392
            if (count($clients) === 0) {
393
                $this->appendNode($nodeClients);
394
                return;
395
            }
396
397
            foreach ($clients as $client) {
398
                $clientName = $this->xml->createElement('name', $this->escapeChars($client->getName()));
399
                $clientDescription = $this->xml->createElement('description', $this->escapeChars($client->getDescription()));
400
401
                // Crear el nodo de clientes
402
                $nodeClient = $this->xml->createElement('Client');
403
                $nodeClient->setAttribute('id', $client->getId());
404
                $nodeClient->appendChild($clientName);
405
                $nodeClient->appendChild($clientDescription);
406
407
                // Añadir cliente al nodo de clientes
408
                $nodeClients->appendChild($nodeClient);
409
            }
410
411
            $this->appendNode($nodeClients);
412
        } catch (Exception $e) {
413
            throw new ServiceException($e->getMessage(), ServiceException::ERROR, __FUNCTION__);
414
        }
415
    }
416
417
    /**
418
     * Crear el nodo con los datos de las etiquetas
419
     *
420
     * @throws ServiceException
421
     * @throws ContainerExceptionInterface
422
     * @throws NotFoundExceptionInterface
423
     */
424
    private function createTags()
425
    {
426
        try {
427
            $this->eventDispatcher->notifyEvent('run.export.process.tag',
428
                new Event($this, EventMessage::factory()
429
                    ->addDescription(__u('Exporting tags')))
430
            );
431
432
            $tagService = $this->dic->get(TagService::class);
433
            $tags = $tagService->getAllBasic();
434
435
            // Crear el nodo de etiquetas
436
            $nodeTags = $this->xml->createElement('Tags');
437
438
            if (count($tags) === 0) {
439
                $this->appendNode($nodeTags);
440
                return;
441
            }
442
443
            foreach ($tags as $tag) {
444
                $tagName = $this->xml->createElement('name', $this->escapeChars($tag->getName()));
445
446
                // Crear el nodo de etiquetas
447
                $nodeTag = $this->xml->createElement('Tag');
448
                $nodeTag->setAttribute('id', $tag->getId());
449
                $nodeTag->appendChild($tagName);
450
451
                // Añadir etiqueta al nodo de etiquetas
452
                $nodeTags->appendChild($nodeTag);
453
            }
454
455
            $this->appendNode($nodeTags);
456
        } catch (Exception $e) {
457
            throw new ServiceException($e->getMessage(), ServiceException::ERROR, __FUNCTION__);
458
        }
459
    }
460
461
    /**
462
     * Crear el nodo con los datos de las cuentas
463
     *
464
     * @throws ServiceException
465
     * @throws ContainerExceptionInterface
466
     * @throws NotFoundExceptionInterface
467
     */
468
    private function createAccounts()
469
    {
470
        try {
471
            $this->eventDispatcher->notifyEvent('run.export.process.account',
472
                new Event($this, EventMessage::factory()
473
                    ->addDescription(__u('Exporting accounts')))
474
            );
475
476
            $accountService = $this->dic->get(AccountService::class);
477
            $accountToTagService = $this->dic->get(AccountToTagService::class);
478
            $accounts = $accountService->getAllBasic();
479
480
            // Crear el nodo de cuentas
481
            $nodeAccounts = $this->xml->createElement('Accounts');
482
483
            if (count($accounts) === 0) {
484
                $this->appendNode($nodeAccounts);
485
                return;
486
            }
487
488
            foreach ($accounts as $account) {
489
                $accountName = $this->xml->createElement('name', $this->escapeChars($account->getName()));
490
                $accountCustomerId = $this->xml->createElement('clientId', $account->getClientId());
491
                $accountCategoryId = $this->xml->createElement('categoryId', $account->getCategoryId());
492
                $accountLogin = $this->xml->createElement('login', $this->escapeChars($account->getLogin()));
493
                $accountUrl = $this->xml->createElement('url', $this->escapeChars($account->getUrl()));
494
                $accountNotes = $this->xml->createElement('notes', $this->escapeChars($account->getNotes()));
495
                $accountPass = $this->xml->createElement('pass', $this->escapeChars($account->getPass()));
496
                $accountIV = $this->xml->createElement('key', $this->escapeChars($account->getKey()));
497
                $tags = $this->xml->createElement('tags');
498
499
                foreach ($accountToTagService->getTagsByAccountId($account->getId()) as $itemData) {
500
                    $tag = $this->xml->createElement('tag');
501
                    $tag->setAttribute('id', $itemData->getId());
502
503
                    $tags->appendChild($tag);
504
                }
505
506
                // Crear el nodo de cuenta
507
                $nodeAccount = $this->xml->createElement('Account');
508
                $nodeAccount->setAttribute('id', $account->getId());
509
                $nodeAccount->appendChild($accountName);
510
                $nodeAccount->appendChild($accountCustomerId);
511
                $nodeAccount->appendChild($accountCategoryId);
512
                $nodeAccount->appendChild($accountLogin);
513
                $nodeAccount->appendChild($accountUrl);
514
                $nodeAccount->appendChild($accountNotes);
515
                $nodeAccount->appendChild($accountPass);
516
                $nodeAccount->appendChild($accountIV);
517
                $nodeAccount->appendChild($tags);
518
519
                // Añadir cuenta al nodo de cuentas
520
                $nodeAccounts->appendChild($nodeAccount);
521
            }
522
523
            $this->appendNode($nodeAccounts);
524
        } catch (Exception $e) {
525
            throw new ServiceException($e->getMessage(), ServiceException::ERROR, __FUNCTION__);
526
        }
527
    }
528
529
    /**
530
     * Crear el hash del archivo XML e insertarlo en el árbol DOM
531
     *
532
     * @throws ServiceException
533
     */
534
    private function createHash()
535
    {
536
        try {
537
            $hash = self::generateHashFromNodes($this->xml);
538
539
            $hashNode = $this->xml->createElement('Hash', $hash);
540
            $hashNode->appendChild($this->xml->createAttribute('sign'));
541
542
            $key = $this->exportPass ?: sha1($this->configData->getPasswordSalt());
543
544
            $hashNode->setAttribute('sign', Hash::signMessage($hash, $key));
545
546
            $this->root
547
                ->getElementsByTagName('Meta')
548
                ->item(0)
549
                ->appendChild($hashNode);
550
        } catch (Exception $e) {
551
            throw new ServiceException($e->getMessage(), ServiceException::ERROR, __FUNCTION__);
552
        }
553
    }
554
555
    /**
556
     * @param DOMDocument $document
557
     *
558
     * @return string
559
     */
560
    public static function generateHashFromNodes(DOMDocument $document): string
561
    {
562
        $data = '';
563
564
        foreach ((new DOMXPath($document))->query('/Root/*[not(self::Meta)]') as $node) {
565
            $data .= $document->saveXML($node);
566
        }
567
568
        return sha1($data);
569
    }
570
571
    /**
572
     * Generar el archivo XML
573
     *
574
     * @throws ServiceException
575
     */
576
    private function writeXML()
577
    {
578
        try {
579
            $this->xml->formatOutput = true;
580
            $this->xml->preserveWhiteSpace = false;
581
582
            if (!$this->xml->save($this->exportFile)) {
583
                throw new ServiceException(__u('Error while creating the XML file'));
584
            }
585
        } catch (Exception $e) {
586
            throw new ServiceException($e->getMessage(), ServiceException::ERROR, __FUNCTION__);
587
        }
588
    }
589
590
    /**
591
     * @throws CheckException
592
     * @throws FileException
593
     */
594
    public function createArchive()
595
    {
596
        $archive = new ArchiveHandler($this->exportFile, $this->extensionChecker);
597
        $archive->compressFile($this->exportFile);
598
599
        $file = new FileHandler($this->exportFile);
600
        $file->delete();
601
    }
602
603
    /**
604
     * @return string
605
     */
606
    public function getExportFile(): string
607
    {
608
        return $this->exportFile;
609
    }
610
611
    /**
612
     * @return bool
613
     */
614
    public function isEncrypted(): bool
615
    {
616
        return $this->encrypted;
617
    }
618
619
    /**
620
     * @throws ContainerExceptionInterface
621
     * @throws NotFoundExceptionInterface
622
     */
623
    protected function initialize()
624
    {
625
        $this->extensionChecker = $this->dic->get(PhpExtensionChecker::class);
626
        $this->configData = $this->config->getConfigData();
627
    }
628
629
    /**
630
     * Devuelve el código XML de un nodo
631
     *
632
     * @param $node string El nodo a devolver
633
     *
634
     * @return string
635
     * @throws ServiceException
636
     */
637
    private function getNodeXML($node)
0 ignored issues
show
Unused Code introduced by
The method getNodeXML() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
638
    {
639
        try {
640
            return $this->xml->saveXML($this->root->getElementsByTagName($node)->item(0));
641
        } catch (Exception $e) {
642
            throw new ServiceException($e->getMessage(), ServiceException::ERROR, __FUNCTION__);
643
        }
644
    }
645
}