Passed
Push — devel-3.0 ( 40e719...463a23 )
by Rubén
03:27
created

XmlExportService::getExportFilename()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 4
nc 2
nop 3
dl 0
loc 9
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * sysPass
4
 *
5
 * @author    nuxsmin
6
 * @link      https://syspass.org
7
 * @copyright 2012-2018, 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 DOMXPath;
28
use Psr\Container\ContainerExceptionInterface;
29
use Psr\Container\NotFoundExceptionInterface;
30
use SP\Config\ConfigData;
31
use SP\Core\AppInfoInterface;
32
use SP\Core\Crypt\Crypt;
33
use SP\Core\Crypt\Hash;
34
use SP\Core\Events\Event;
35
use SP\Core\Events\EventMessage;
36
use SP\Core\PhpExtensionChecker;
37
use SP\DataModel\CategoryData;
38
use SP\Services\Account\AccountService;
39
use SP\Services\Account\AccountToTagService;
40
use SP\Services\Category\CategoryService;
41
use SP\Services\Client\ClientService;
42
use SP\Services\Service;
43
use SP\Services\ServiceException;
44
use SP\Services\Tag\TagService;
45
use SP\Storage\File\ArchiveHandler;
46
use SP\Storage\File\FileHandler;
47
use SP\Util\VersionUtil;
48
49
defined('APP_ROOT') || die();
50
51
/**
52
 * Clase XmlExport para realizar la exportación de las cuentas de sysPass a formato XML
53
 *
54
 * @package SP
55
 */
56
final class XmlExportService extends Service
57
{
58
    /**
59
     * @var ConfigData
60
     */
61
    private $configData;
62
    /**
63
     * @var
64
     */
65
    private $extensionChecker;
66
    /**
67
     * @var \DOMDocument
68
     */
69
    private $xml;
70
    /**
71
     * @var \DOMElement
72
     */
73
    private $root;
74
    /**
75
     * @var string
76
     */
77
    private $exportPass;
78
    /**
79
     * @var bool
80
     */
81
    private $encrypted = false;
82
    /**
83
     * @var string
84
     */
85
    private $exportPath;
86
    /**
87
     * @var string
88
     */
89
    private $exportFile;
90
91
    /**
92
     * Realiza la exportación de las cuentas a XML
93
     *
94
     * @param string $exportPath
95
     * @param string $pass string La clave de exportación
96
     *
97
     * @throws ServiceException
98
     * @throws \SP\Storage\File\FileException
99
     */
100
    public function doExport(string $exportPath, string $pass = null)
101
    {
102
        if (!empty($pass)) {
103
            $this->exportPass = $pass;
104
            $this->encrypted = true;
105
        }
106
107
        $this->setExportPath($exportPath);
108
        $this->exportFile = $this->generateExportFilename();
109
        $this->deleteOldExports();
110
        $this->makeXML();
111
    }
112
113
    /**
114
     * @param string $exportPath
115
     *
116
     * @throws ServiceException
117
     */
118
    private function setExportPath(string $exportPath)
119
    {
120
        if (!is_dir($exportPath)
121
            && @mkdir($exportPath, 0700, true) === false
122
        ) {
123
            throw new ServiceException(sprintf(__('No es posible crear el directorio (%s)'), $exportPath));
124
        }
125
126
        $this->exportPath = $exportPath;
127
    }
128
129
    /**
130
     * Genera el nombre del archivo usado para la exportación.
131
     *
132
     * @throws \SP\Storage\File\FileException
133
     */
134
    private function generateExportFilename(): string
135
    {
136
        // Generar hash unico para evitar descargas no permitidas
137
        $hash = sha1(uniqid('sysPassExport', true));
138
        $this->configData->setExportHash($hash);
139
        $this->config->saveConfig($this->configData);
140
141
        return self::getExportFilename($this->exportPath, $hash);
142
    }
143
144
    /**
145
     * @param string $path
146
     * @param string $hash
147
     * @param bool   $compressed
148
     *
149
     * @return string
150
     */
151
    public static function getExportFilename(string $path, string $hash, bool $compressed = false)
152
    {
153
        $file = $path . DIRECTORY_SEPARATOR . AppInfoInterface::APP_NAME . '_export-' . $hash;
154
155
        if ($compressed) {
156
            return $file . ArchiveHandler::COMPRESS_EXTENSION;
157
        }
158
159
        return $file . '.xml';
160
    }
161
162
    /**
163
     * Eliminar los archivos de exportación anteriores
164
     */
165
    private function deleteOldExports()
166
    {
167
        $path = $this->exportPath . DIRECTORY_SEPARATOR . AppInfoInterface::APP_NAME;
168
169
        array_map(function ($file) {
170
            return @unlink($file);
171
        }, array_merge(glob($path . '_export-*'), glob($path . '*.xml')));
172
    }
173
174
    /**
175
     * Crear el documento XML y guardarlo
176
     *
177
     * @throws ContainerExceptionInterface
178
     * @throws NotFoundExceptionInterface
179
     * @throws ServiceException
180
     */
181
    private function makeXML()
182
    {
183
        try {
184
            $this->createRoot();
185
            $this->createMeta();
186
            $this->createCategories();
187
            $this->createClients();
188
            $this->createTags();
189
            $this->createAccounts();
190
            $this->createHash();
191
            $this->writeXML();
192
        } catch (ServiceException $e) {
193
            throw $e;
194
        } catch (\Exception $e) {
195
            throw new ServiceException(
196
                __u('Error al realizar la exportación'),
197
                ServiceException::ERROR,
198
                __u('Revise el registro de eventos para más detalles'),
199
                $e->getCode(),
200
                $e
201
            );
202
        }
203
    }
204
205
    /**
206
     * Crear el nodo raíz
207
     *
208
     * @throws ServiceException
209
     */
210
    private function createRoot()
211
    {
212
        try {
213
            $root = $this->xml->createElement('Root');
214
            $this->root = $this->xml->appendChild($root);
215
        } catch (\Exception $e) {
216
            throw new ServiceException($e->getMessage(), ServiceException::ERROR, __FUNCTION__);
217
        }
218
    }
219
220
    /**
221
     * Crear el nodo con metainformación del archivo XML
222
     *
223
     * @throws ServiceException
224
     */
225
    private function createMeta()
226
    {
227
        try {
228
            $userData = $this->context->getUserData();
229
230
            $nodeMeta = $this->xml->createElement('Meta');
231
            $metaGenerator = $this->xml->createElement('Generator', 'sysPass');
232
            $metaVersion = $this->xml->createElement('Version', VersionUtil::getVersionStringNormalized());
233
            $metaTime = $this->xml->createElement('Time', time());
234
            $metaUser = $this->xml->createElement('User', $userData->getLogin());
235
            $metaUser->setAttribute('id', $userData->getId());
236
            // FIXME: get user group name
237
            $metaGroup = $this->xml->createElement('Group', '');
238
            $metaGroup->setAttribute('id', $userData->getUserGroupId());
239
240
            $nodeMeta->appendChild($metaGenerator);
241
            $nodeMeta->appendChild($metaVersion);
242
            $nodeMeta->appendChild($metaTime);
243
            $nodeMeta->appendChild($metaUser);
244
            $nodeMeta->appendChild($metaGroup);
245
246
            $this->root->appendChild($nodeMeta);
247
        } catch (\Exception $e) {
248
            throw new ServiceException($e->getMessage(), ServiceException::ERROR, __FUNCTION__);
249
        }
250
    }
251
252
    /**
253
     * Crear el nodo con los datos de las categorías
254
     *
255
     * @throws ContainerExceptionInterface
256
     * @throws NotFoundExceptionInterface
257
     * @throws ServiceException
258
     */
259
    private function createCategories()
260
    {
261
        try {
262
            $this->eventDispatcher->notifyEvent('run.export.process.category',
263
                new Event($this, EventMessage::factory()
264
                    ->addDescription(__u('Exportando categorías')))
265
            );
266
267
            $categoryService = $this->dic->get(CategoryService::class);
268
            $categories = $categoryService->getAllBasic();
269
270
            // Crear el nodo de categorías
271
            $nodeCategories = $this->xml->createElement('Categories');
272
273
            if (count($categories) === 0) {
274
                $this->appendNode($nodeCategories);
275
276
                return;
277
            }
278
279
            foreach ($categories as $category) {
280
                /** @var $category CategoryData */
281
                $categoryName = $this->xml->createElement('name', $this->escapeChars($category->getName()));
282
                $categoryDescription = $this->xml->createElement('description', $this->escapeChars($category->getDescription()));
283
284
                // Crear el nodo de categoría
285
                $nodeCategory = $this->xml->createElement('Category');
286
                $nodeCategory->setAttribute('id', $category->getId());
287
                $nodeCategory->appendChild($categoryName);
288
                $nodeCategory->appendChild($categoryDescription);
289
290
                // Añadir categoría al nodo de categorías
291
                $nodeCategories->appendChild($nodeCategory);
292
            }
293
294
            $this->appendNode($nodeCategories);
295
        } catch (\Exception $e) {
296
            throw new ServiceException($e->getMessage(), ServiceException::ERROR, __FUNCTION__);
297
        }
298
    }
299
300
    /**
301
     * Añadir un nuevo nodo al árbol raíz
302
     *
303
     * @param \DOMElement $node El nodo a añadir
304
     *
305
     * @throws ServiceException
306
     */
307
    private function appendNode(\DOMElement $node)
308
    {
309
        try {
310
            // Si se utiliza clave de encriptación los datos se encriptan en un nuevo nodo:
311
            // Encrypted -> Data
312
            if ($this->encrypted === true) {
313
                // Obtener el nodo en formato XML
314
                $nodeXML = $this->xml->saveXML($node);
315
316
                // Crear los datos encriptados con la información del nodo
317
                $securedKey = Crypt::makeSecuredKey($this->exportPass);
318
                $encrypted = Crypt::encrypt($nodeXML, $securedKey, $this->exportPass);
319
320
                // Buscar si existe ya un nodo para el conjunto de datos encriptados
321
                $encryptedNode = $this->root->getElementsByTagName('Encrypted')->item(0);
322
323
                if (!$encryptedNode instanceof \DOMElement) {
324
                    $encryptedNode = $this->xml->createElement('Encrypted');
325
                    $encryptedNode->setAttribute('hash', Hash::hashKey($this->exportPass));
326
                }
327
328
                // Crear el nodo hijo con los datos encriptados
329
                $encryptedData = $this->xml->createElement('Data', base64_encode($encrypted));
330
331
                $encryptedDataIV = $this->xml->createAttribute('key');
332
                $encryptedDataIV->value = $securedKey;
333
334
                // Añadir nodos de datos
335
                $encryptedData->appendChild($encryptedDataIV);
336
                $encryptedNode->appendChild($encryptedData);
337
338
                // Añadir el nodo encriptado
339
                $this->root->appendChild($encryptedNode);
340
            } else {
341
                $this->root->appendChild($node);
342
            }
343
        } catch (\Exception $e) {
344
            throw new ServiceException($e->getMessage(), ServiceException::ERROR, __FUNCTION__);
345
        }
346
    }
347
348
    /**
349
     * Escapar carácteres no válidos en XML
350
     *
351
     * @param $data string Los datos a escapar
352
     *
353
     * @return mixed
354
     */
355
    private function escapeChars($data)
356
    {
357
        $arrStrFrom = ['&', '<', '>', '"', '\''];
358
        $arrStrTo = ['&#38;', '&#60;', '&#62;', '&#34;', '&#39;'];
359
360
        return str_replace($arrStrFrom, $arrStrTo, $data);
361
    }
362
363
    /**
364
     * Crear el nodo con los datos de los clientes
365
     *
366
     * @throws ServiceException
367
     * @throws ServiceException
368
     * @throws \Psr\Container\ContainerExceptionInterface
369
     * @throws \Psr\Container\NotFoundExceptionInterface
370
     */
371
    private function createClients()
372
    {
373
        try {
374
            $this->eventDispatcher->notifyEvent('run.export.process.client',
375
                new Event($this, EventMessage::factory()
376
                    ->addDescription(__u('Exportando clientes')))
377
            );
378
379
            $clientService = $this->dic->get(ClientService::class);
380
            $clients = $clientService->getAllBasic();
381
382
            // Crear el nodo de clientes
383
            $nodeClients = $this->xml->createElement('Clients');
384
385
            if (count($clients) === 0) {
386
                $this->appendNode($nodeClients);
387
                return;
388
            }
389
390
            foreach ($clients as $client) {
391
                $clientName = $this->xml->createElement('name', $this->escapeChars($client->getName()));
392
                $clientDescription = $this->xml->createElement('description', $this->escapeChars($client->getDescription()));
393
394
                // Crear el nodo de clientes
395
                $nodeClient = $this->xml->createElement('Client');
396
                $nodeClient->setAttribute('id', $client->getId());
397
                $nodeClient->appendChild($clientName);
398
                $nodeClient->appendChild($clientDescription);
399
400
                // Añadir cliente al nodo de clientes
401
                $nodeClients->appendChild($nodeClient);
402
            }
403
404
            $this->appendNode($nodeClients);
405
        } catch (\Exception $e) {
406
            throw new ServiceException($e->getMessage(), ServiceException::ERROR, __FUNCTION__);
407
        }
408
    }
409
410
    /**
411
     * Crear el nodo con los datos de las etiquetas
412
     *
413
     * @throws ServiceException
414
     * @throws \Psr\Container\ContainerExceptionInterface
415
     * @throws \Psr\Container\NotFoundExceptionInterface
416
     */
417
    private function createTags()
418
    {
419
        try {
420
            $this->eventDispatcher->notifyEvent('run.export.process.tag',
421
                new Event($this, EventMessage::factory()
422
                    ->addDescription(__u('Exportando etiquetas')))
423
            );
424
425
            $tagService = $this->dic->get(TagService::class);
426
            $tags = $tagService->getAllBasic();
427
428
            // Crear el nodo de etiquetas
429
            $nodeTags = $this->xml->createElement('Tags');
430
431
            if (count($tags) === 0) {
432
                $this->appendNode($nodeTags);
433
                return;
434
            }
435
436
            foreach ($tags as $tag) {
437
                $tagName = $this->xml->createElement('name', $this->escapeChars($tag->getName()));
438
439
                // Crear el nodo de etiquetas
440
                $nodeTag = $this->xml->createElement('Tag');
441
                $nodeTag->setAttribute('id', $tag->getId());
442
                $nodeTag->appendChild($tagName);
443
444
                // Añadir etiqueta al nodo de etiquetas
445
                $nodeTags->appendChild($nodeTag);
446
            }
447
448
            $this->appendNode($nodeTags);
449
        } catch (\Exception $e) {
450
            throw new ServiceException($e->getMessage(), ServiceException::ERROR, __FUNCTION__);
451
        }
452
    }
453
454
    /**
455
     * Crear el nodo con los datos de las cuentas
456
     *
457
     * @throws ServiceException
458
     * @throws \Psr\Container\ContainerExceptionInterface
459
     * @throws \Psr\Container\NotFoundExceptionInterface
460
     */
461
    private function createAccounts()
462
    {
463
        try {
464
            $this->eventDispatcher->notifyEvent('run.export.process.account',
465
                new Event($this, EventMessage::factory()
466
                    ->addDescription(__u('Exportando cuentas')))
467
            );
468
469
            $accountService = $this->dic->get(AccountService::class);
470
            $accountToTagService = $this->dic->get(AccountToTagService::class);
471
            $accounts = $accountService->getAllBasic();
472
473
            // Crear el nodo de cuentas
474
            $nodeAccounts = $this->xml->createElement('Accounts');
475
476
            if (count($accounts) === 0) {
477
                $this->appendNode($nodeAccounts);
478
                return;
479
            }
480
481
            foreach ($accounts as $account) {
482
                $accountName = $this->xml->createElement('name', $this->escapeChars($account->getName()));
483
                $accountCustomerId = $this->xml->createElement('clientId', $account->getClientId());
484
                $accountCategoryId = $this->xml->createElement('categoryId', $account->getCategoryId());
485
                $accountLogin = $this->xml->createElement('login', $this->escapeChars($account->getLogin()));
486
                $accountUrl = $this->xml->createElement('url', $this->escapeChars($account->getUrl()));
487
                $accountNotes = $this->xml->createElement('notes', $this->escapeChars($account->getNotes()));
488
                $accountPass = $this->xml->createElement('pass', $this->escapeChars($account->getPass()));
489
                $accountIV = $this->xml->createElement('key', $this->escapeChars($account->getKey()));
490
                $tags = $this->xml->createElement('tags');
491
492
                foreach ($accountToTagService->getTagsByAccountId($account->getId()) as $itemData) {
493
                    $tag = $this->xml->createElement('tag');
494
                    $tag->setAttribute('id', $itemData->getId());
495
496
                    $tags->appendChild($tag);
497
                }
498
499
                // Crear el nodo de cuenta
500
                $nodeAccount = $this->xml->createElement('Account');
501
                $nodeAccount->setAttribute('id', $account->getId());
502
                $nodeAccount->appendChild($accountName);
503
                $nodeAccount->appendChild($accountCustomerId);
504
                $nodeAccount->appendChild($accountCategoryId);
505
                $nodeAccount->appendChild($accountLogin);
506
                $nodeAccount->appendChild($accountUrl);
507
                $nodeAccount->appendChild($accountNotes);
508
                $nodeAccount->appendChild($accountPass);
509
                $nodeAccount->appendChild($accountIV);
510
                $nodeAccount->appendChild($tags);
511
512
                // Añadir cuenta al nodo de cuentas
513
                $nodeAccounts->appendChild($nodeAccount);
514
            }
515
516
            $this->appendNode($nodeAccounts);
517
        } catch (\Exception $e) {
518
            throw new ServiceException($e->getMessage(), ServiceException::ERROR, __FUNCTION__);
519
        }
520
    }
521
522
    /**
523
     * Crear el hash del archivo XML e insertarlo en el árbol DOM
524
     *
525
     * @throws ServiceException
526
     */
527
    private function createHash()
528
    {
529
        try {
530
            $hash = self::generateHashFromNodes($this->xml);
531
532
            $hashNode = $this->xml->createElement('Hash', $hash);
533
            $hashNode->appendChild($this->xml->createAttribute('sign'));
534
535
            $key = $this->exportPass ?: sha1($this->configData->getPasswordSalt());
536
537
            $hashNode->setAttribute('sign', Hash::signMessage($hash, $key));
538
539
            $this->root
540
                ->getElementsByTagName('Meta')
541
                ->item(0)
542
                ->appendChild($hashNode);
543
        } catch (\Exception $e) {
544
            throw new ServiceException($e->getMessage(), ServiceException::ERROR, __FUNCTION__);
545
        }
546
    }
547
548
    /**
549
     * @param \DOMDocument $document
550
     *
551
     * @return string
552
     */
553
    public static function generateHashFromNodes(\DOMDocument $document): string
554
    {
555
        $data = '';
556
557
        foreach ((new DOMXPath($document))->query('/Root/*[not(self::Meta)]') as $node) {
558
            $data .= $document->saveXML($node);
559
        }
560
561
        return sha1($data);
562
    }
563
564
    /**
565
     * Generar el archivo XML
566
     *
567
     * @throws ServiceException
568
     */
569
    private function writeXML()
570
    {
571
        try {
572
            $this->xml->formatOutput = true;
573
            $this->xml->preserveWhiteSpace = false;
574
575
            if (!$this->xml->save($this->exportFile)) {
576
                throw new ServiceException(__u('Error al crear el archivo XML'));
577
            }
578
        } catch (\Exception $e) {
579
            throw new ServiceException($e->getMessage(), ServiceException::ERROR, __FUNCTION__);
580
        }
581
    }
582
583
    /**
584
     * @throws \SP\Core\Exceptions\CheckException
585
     * @throws \SP\Storage\File\FileException
586
     */
587
    public function createArchive()
588
    {
589
        $archive = new ArchiveHandler($this->exportFile, $this->extensionChecker);
590
        $archive->compressFile($this->exportFile);
591
592
        $file = new FileHandler($this->exportFile);
593
        $file->delete();
594
    }
595
596
    /**
597
     * @return string
598
     */
599
    public function getExportFile(): string
600
    {
601
        return $this->exportFile;
602
    }
603
604
    /**
605
     * @return bool
606
     */
607
    public function isEncrypted(): bool
608
    {
609
        return $this->encrypted;
610
    }
611
612
    /**
613
     * @throws \Psr\Container\ContainerExceptionInterface
614
     * @throws \Psr\Container\NotFoundExceptionInterface
615
     */
616
    protected function initialize()
617
    {
618
        $this->extensionChecker = $this->dic->get(PhpExtensionChecker::class);
619
        $this->configData = $this->config->getConfigData();
620
        $this->xml = new \DOMDocument('1.0', 'UTF-8');
621
    }
622
623
    /**
624
     * Devuelve el código XML de un nodo
625
     *
626
     * @param $node string El nodo a devolver
627
     *
628
     * @return string
629
     * @throws ServiceException
630
     */
631
    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...
632
    {
633
        try {
634
            return $this->xml->saveXML($this->root->getElementsByTagName($node)->item(0));
635
        } catch (\Exception $e) {
636
            throw new ServiceException($e->getMessage(), ServiceException::ERROR, __FUNCTION__);
637
        }
638
    }
639
}