. */ namespace SP\Services\Export; use DOMDocument; use DOMElement; use DOMXPath; use Exception; use Psr\Container\ContainerExceptionInterface; use Psr\Container\NotFoundExceptionInterface; use SP\Config\ConfigData; use SP\Core\AppInfoInterface; use SP\Core\Crypt\Crypt; use SP\Core\Crypt\Hash; use SP\Core\Events\Event; use SP\Core\Events\EventMessage; use SP\Core\Exceptions\CheckException; use SP\Core\PhpExtensionChecker; use SP\DataModel\CategoryData; use SP\Services\Account\AccountService; use SP\Services\Account\AccountToTagService; use SP\Services\Category\CategoryService; use SP\Services\Client\ClientService; use SP\Services\Service; use SP\Services\ServiceException; use SP\Services\Tag\TagService; use SP\Storage\File\ArchiveHandler; use SP\Storage\File\FileException; use SP\Storage\File\FileHandler; use SP\Util\VersionUtil; defined('APP_ROOT') || die(); /** * Clase XmlExport para realizar la exportación de las cuentas de sysPass a formato XML * * @package SP */ final class XmlExportService extends Service { /** * @var ConfigData */ private $configData; /** * @var */ private $extensionChecker; /** * @var DOMDocument */ private $xml; /** * @var DOMElement */ private $root; /** * @var string */ private $exportPass; /** * @var bool */ private $encrypted = false; /** * @var string */ private $exportPath; /** * @var string */ private $exportFile; /** * Realiza la exportación de las cuentas a XML * * @param string $exportPath * @param string $pass string La clave de exportación * * @throws ServiceException * @throws FileException */ public function doExport(string $exportPath, string $pass = null) { set_time_limit(0); if (!empty($pass)) { $this->exportPass = $pass; $this->encrypted = true; } $this->setExportPath($exportPath); $this->exportFile = $this->generateExportFilename(); $this->deleteOldExports(); $this->makeXML(); } /** * @param string $exportPath * * @throws ServiceException */ private function setExportPath(string $exportPath) { if (!is_dir($exportPath) && @mkdir($exportPath, 0700, true) === false ) { throw new ServiceException(sprintf(__('Unable to create the directory (%s)'), $exportPath)); } $this->exportPath = $exportPath; } /** * Genera el nombre del archivo usado para la exportación. * * @return string * @throws FileException */ private function generateExportFilename(): string { // Generar hash unico para evitar descargas no permitidas $hash = sha1(uniqid('sysPassExport', true)); $this->configData->setExportHash($hash); $this->config->saveConfig($this->configData); return self::getExportFilename($this->exportPath, $hash); } /** * @param string $path * @param string $hash * @param bool $compressed * * @return string */ public static function getExportFilename(string $path, string $hash, bool $compressed = false) { $file = $path . DIRECTORY_SEPARATOR . AppInfoInterface::APP_NAME . '_export-' . $hash; if ($compressed) { return $file . ArchiveHandler::COMPRESS_EXTENSION; } return $file . '.xml'; } /** * Eliminar los archivos de exportación anteriores */ private function deleteOldExports() { $path = $this->exportPath . DIRECTORY_SEPARATOR . AppInfoInterface::APP_NAME; array_map(function ($file) { return @unlink($file); }, array_merge(glob($path . '_export-*'), glob($path . '*.xml'))); } /** * Crear el documento XML y guardarlo * * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface * @throws ServiceException */ private function makeXML() { try { $this->createRoot(); $this->createMeta(); $this->createCategories(); $this->createClients(); $this->createTags(); $this->createAccounts(); $this->createHash(); $this->writeXML(); } catch (ServiceException $e) { throw $e; } catch (Exception $e) { throw new ServiceException( __u('Error while exporting'), ServiceException::ERROR, __u('Please check out the event log for more details'), $e->getCode(), $e ); } } /** * Crear el nodo raíz * * @throws ServiceException */ private function createRoot() { try { $this->xml = new DOMDocument('1.0', 'UTF-8'); $this->root = $this->xml->appendChild($this->xml->createElement('Root')); } catch (Exception $e) { throw new ServiceException($e->getMessage(), ServiceException::ERROR, __FUNCTION__); } } /** * Crear el nodo con metainformación del archivo XML * * @throws ServiceException */ private function createMeta() { try { $userData = $this->context->getUserData(); $nodeMeta = $this->xml->createElement('Meta'); $metaGenerator = $this->xml->createElement('Generator', 'sysPass'); $metaVersion = $this->xml->createElement('Version', VersionUtil::getVersionStringNormalized()); $metaTime = $this->xml->createElement('Time', time()); $metaUser = $this->xml->createElement('User', $userData->getLogin()); $metaUser->setAttribute('id', $userData->getId()); $metaGroup = $this->xml->createElement('Group', $userData->getUserGroupName()); $metaGroup->setAttribute('id', $userData->getUserGroupId()); $nodeMeta->appendChild($metaGenerator); $nodeMeta->appendChild($metaVersion); $nodeMeta->appendChild($metaTime); $nodeMeta->appendChild($metaUser); $nodeMeta->appendChild($metaGroup); $this->root->appendChild($nodeMeta); } catch (Exception $e) { throw new ServiceException($e->getMessage(), ServiceException::ERROR, __FUNCTION__); } } /** * Crear el nodo con los datos de las categorías * * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface * @throws ServiceException */ private function createCategories() { try { $this->eventDispatcher->notifyEvent('run.export.process.category', new Event($this, EventMessage::factory() ->addDescription(__u('Exporting categories'))) ); $categoryService = $this->dic->get(CategoryService::class); $categories = $categoryService->getAllBasic(); // Crear el nodo de categorías $nodeCategories = $this->xml->createElement('Categories'); if (count($categories) === 0) { $this->appendNode($nodeCategories); return; } foreach ($categories as $category) { /** @var $category CategoryData */ $categoryName = $this->xml->createElement('name', $this->escapeChars($category->getName())); $categoryDescription = $this->xml->createElement('description', $this->escapeChars($category->getDescription())); // Crear el nodo de categoría $nodeCategory = $this->xml->createElement('Category'); $nodeCategory->setAttribute('id', $category->getId()); $nodeCategory->appendChild($categoryName); $nodeCategory->appendChild($categoryDescription); // Añadir categoría al nodo de categorías $nodeCategories->appendChild($nodeCategory); } $this->appendNode($nodeCategories); } catch (Exception $e) { throw new ServiceException($e->getMessage(), ServiceException::ERROR, __FUNCTION__); } } /** * Añadir un nuevo nodo al árbol raíz * * @param DOMElement $node El nodo a añadir * * @throws ServiceException */ private function appendNode(DOMElement $node) { try { // Si se utiliza clave de encriptación los datos se encriptan en un nuevo nodo: // Encrypted -> Data if ($this->encrypted === true) { // Obtener el nodo en formato XML $nodeXML = $this->xml->saveXML($node); // Crear los datos encriptados con la información del nodo $securedKey = Crypt::makeSecuredKey($this->exportPass); $encrypted = Crypt::encrypt($nodeXML, $securedKey, $this->exportPass); // Buscar si existe ya un nodo para el conjunto de datos encriptados $encryptedNode = $this->root->getElementsByTagName('Encrypted')->item(0); if (!$encryptedNode instanceof DOMElement) { $encryptedNode = $this->xml->createElement('Encrypted'); $encryptedNode->setAttribute('hash', Hash::hashKey($this->exportPass)); } // Crear el nodo hijo con los datos encriptados $encryptedData = $this->xml->createElement('Data', base64_encode($encrypted)); $encryptedDataKey = $this->xml->createAttribute('key'); $encryptedDataKey->value = $securedKey; // Añadir nodos de datos $encryptedData->appendChild($encryptedDataKey); $encryptedNode->appendChild($encryptedData); // Añadir el nodo encriptado $this->root->appendChild($encryptedNode); } else { $this->root->appendChild($node); } } catch (Exception $e) { throw new ServiceException($e->getMessage(), ServiceException::ERROR, __FUNCTION__); } } /** * Escapar carácteres no válidos en XML * * @param $data string Los datos a escapar * * @return mixed */ private function escapeChars($data) { $arrStrFrom = ['&', '<', '>', '"', '\'']; $arrStrTo = ['&', '<', '>', '"', ''']; return str_replace($arrStrFrom, $arrStrTo, $data); } /** * Crear el nodo con los datos de los clientes * * @throws ServiceException * @throws ServiceException * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface */ private function createClients() { try { $this->eventDispatcher->notifyEvent('run.export.process.client', new Event($this, EventMessage::factory() ->addDescription(__u('Exporting clients'))) ); $clientService = $this->dic->get(ClientService::class); $clients = $clientService->getAllBasic(); // Crear el nodo de clientes $nodeClients = $this->xml->createElement('Clients'); if (count($clients) === 0) { $this->appendNode($nodeClients); return; } foreach ($clients as $client) { $clientName = $this->xml->createElement('name', $this->escapeChars($client->getName())); $clientDescription = $this->xml->createElement('description', $this->escapeChars($client->getDescription())); // Crear el nodo de clientes $nodeClient = $this->xml->createElement('Client'); $nodeClient->setAttribute('id', $client->getId()); $nodeClient->appendChild($clientName); $nodeClient->appendChild($clientDescription); // Añadir cliente al nodo de clientes $nodeClients->appendChild($nodeClient); } $this->appendNode($nodeClients); } catch (Exception $e) { throw new ServiceException($e->getMessage(), ServiceException::ERROR, __FUNCTION__); } } /** * Crear el nodo con los datos de las etiquetas * * @throws ServiceException * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface */ private function createTags() { try { $this->eventDispatcher->notifyEvent('run.export.process.tag', new Event($this, EventMessage::factory() ->addDescription(__u('Exporting tags'))) ); $tagService = $this->dic->get(TagService::class); $tags = $tagService->getAllBasic(); // Crear el nodo de etiquetas $nodeTags = $this->xml->createElement('Tags'); if (count($tags) === 0) { $this->appendNode($nodeTags); return; } foreach ($tags as $tag) { $tagName = $this->xml->createElement('name', $this->escapeChars($tag->getName())); // Crear el nodo de etiquetas $nodeTag = $this->xml->createElement('Tag'); $nodeTag->setAttribute('id', $tag->getId()); $nodeTag->appendChild($tagName); // Añadir etiqueta al nodo de etiquetas $nodeTags->appendChild($nodeTag); } $this->appendNode($nodeTags); } catch (Exception $e) { throw new ServiceException($e->getMessage(), ServiceException::ERROR, __FUNCTION__); } } /** * Crear el nodo con los datos de las cuentas * * @throws ServiceException * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface */ private function createAccounts() { try { $this->eventDispatcher->notifyEvent('run.export.process.account', new Event($this, EventMessage::factory() ->addDescription(__u('Exporting accounts'))) ); $accountService = $this->dic->get(AccountService::class); $accountToTagService = $this->dic->get(AccountToTagService::class); $accounts = $accountService->getAllBasic(); // Crear el nodo de cuentas $nodeAccounts = $this->xml->createElement('Accounts'); if (count($accounts) === 0) { $this->appendNode($nodeAccounts); return; } foreach ($accounts as $account) { $accountName = $this->xml->createElement('name', $this->escapeChars($account->getName())); $accountCustomerId = $this->xml->createElement('clientId', $account->getClientId()); $accountCategoryId = $this->xml->createElement('categoryId', $account->getCategoryId()); $accountLogin = $this->xml->createElement('login', $this->escapeChars($account->getLogin())); $accountUrl = $this->xml->createElement('url', $this->escapeChars($account->getUrl())); $accountNotes = $this->xml->createElement('notes', $this->escapeChars($account->getNotes())); $accountPass = $this->xml->createElement('pass', $this->escapeChars($account->getPass())); $accountIV = $this->xml->createElement('key', $this->escapeChars($account->getKey())); $tags = $this->xml->createElement('tags'); foreach ($accountToTagService->getTagsByAccountId($account->getId()) as $itemData) { $tag = $this->xml->createElement('tag'); $tag->setAttribute('id', $itemData->getId()); $tags->appendChild($tag); } // Crear el nodo de cuenta $nodeAccount = $this->xml->createElement('Account'); $nodeAccount->setAttribute('id', $account->getId()); $nodeAccount->appendChild($accountName); $nodeAccount->appendChild($accountCustomerId); $nodeAccount->appendChild($accountCategoryId); $nodeAccount->appendChild($accountLogin); $nodeAccount->appendChild($accountUrl); $nodeAccount->appendChild($accountNotes); $nodeAccount->appendChild($accountPass); $nodeAccount->appendChild($accountIV); $nodeAccount->appendChild($tags); // Añadir cuenta al nodo de cuentas $nodeAccounts->appendChild($nodeAccount); } $this->appendNode($nodeAccounts); } catch (Exception $e) { throw new ServiceException($e->getMessage(), ServiceException::ERROR, __FUNCTION__); } } /** * Crear el hash del archivo XML e insertarlo en el árbol DOM * * @throws ServiceException */ private function createHash() { try { $hash = self::generateHashFromNodes($this->xml); $hashNode = $this->xml->createElement('Hash', $hash); $hashNode->appendChild($this->xml->createAttribute('sign')); $key = $this->exportPass ?: sha1($this->configData->getPasswordSalt()); $hashNode->setAttribute('sign', Hash::signMessage($hash, $key)); $this->root ->getElementsByTagName('Meta') ->item(0) ->appendChild($hashNode); } catch (Exception $e) { throw new ServiceException($e->getMessage(), ServiceException::ERROR, __FUNCTION__); } } /** * @param DOMDocument $document * * @return string */ public static function generateHashFromNodes(DOMDocument $document): string { $data = ''; foreach ((new DOMXPath($document))->query('/Root/*[not(self::Meta)]') as $node) { $data .= $document->saveXML($node); } return sha1($data); } /** * Generar el archivo XML * * @throws ServiceException */ private function writeXML() { try { $this->xml->formatOutput = true; $this->xml->preserveWhiteSpace = false; if (!$this->xml->save($this->exportFile)) { throw new ServiceException(__u('Error while creating the XML file')); } } catch (Exception $e) { throw new ServiceException($e->getMessage(), ServiceException::ERROR, __FUNCTION__); } } /** * @throws CheckException * @throws FileException */ public function createArchive() { $archive = new ArchiveHandler($this->exportFile, $this->extensionChecker); $archive->compressFile($this->exportFile); $file = new FileHandler($this->exportFile); $file->delete(); } /** * @return string */ public function getExportFile(): string { return $this->exportFile; } /** * @return bool */ public function isEncrypted(): bool { return $this->encrypted; } /** * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface */ protected function initialize() { $this->extensionChecker = $this->dic->get(PhpExtensionChecker::class); $this->configData = $this->config->getConfigData(); } /** * Devuelve el código XML de un nodo * * @param $node string El nodo a devolver * * @return string * @throws ServiceException */ private function getNodeXML($node) { try { return $this->xml->saveXML($this->root->getElementsByTagName($node)->item(0)); } catch (Exception $e) { throw new ServiceException($e->getMessage(), ServiceException::ERROR, __FUNCTION__); } } }