feat: full anonymization of buspro xml dumps
This commit is contained in:
@@ -27,8 +27,8 @@ class BpnXmlAnonymizeCommand extends Command
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('infile', InputArgument::REQUIRED, 'Path to the input XML file')
|
||||
->addArgument('outfile', InputArgument::OPTIONAL, 'Path to the output XML file (defaults to infile)')
|
||||
->addArgument('infile', InputArgument::REQUIRED, 'Path to the input XML file or folder')
|
||||
->addArgument('outfile', InputArgument::OPTIONAL, 'Path to the output XML file or folder (defaults to infile)')
|
||||
;
|
||||
}
|
||||
|
||||
@@ -39,19 +39,31 @@ class BpnXmlAnonymizeCommand extends Command
|
||||
$inputFile = (string) $input->getArgument('infile');
|
||||
$outputFile = $input->getArgument('outfile');
|
||||
|
||||
if (true === is_dir($inputFile)) {
|
||||
$targetDirectory = null === $outputFile ? $inputFile : (string) $outputFile;
|
||||
|
||||
return $this->anonymizeDirectory($io, $inputFile, $targetDirectory);
|
||||
}
|
||||
|
||||
if (false === is_file($inputFile)) {
|
||||
$io->error(sprintf('Input file not found: %s', $inputFile));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$targetFile = null === $outputFile ? $inputFile : (string) $outputFile;
|
||||
|
||||
return $this->anonymizeSingleFile($io, $inputFile, $targetFile);
|
||||
}
|
||||
|
||||
private function anonymizeSingleFile(SymfonyStyle $io, string $inputFile, string $targetFile): int
|
||||
{
|
||||
if (false === is_readable($inputFile)) {
|
||||
$io->error(sprintf('Input file is not readable: %s', $inputFile));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$targetFile = null === $outputFile ? $inputFile : (string) $outputFile;
|
||||
$this->xmlAnonymizer->resetState();
|
||||
|
||||
$processingResult = $this->anonymizeFile($io, $inputFile, $targetFile, false);
|
||||
@@ -85,12 +97,120 @@ class BpnXmlAnonymizeCommand extends Command
|
||||
$io->note(sprintf('Paired file anonymized: %s -> %s', $pairedInputFile, $pairedTargetFile));
|
||||
}
|
||||
|
||||
$io->success(sprintf(
|
||||
'Anonymized %d persons from %s to %s',
|
||||
$this->xmlAnonymizer->getLastIdentityCount(),
|
||||
$inputFile,
|
||||
$targetFile,
|
||||
));
|
||||
$identityCount = $this->xmlAnonymizer->getLastIdentityCount();
|
||||
if (0 === $identityCount) {
|
||||
$io->warning(sprintf('No anonymizable person data found in %s', $inputFile));
|
||||
} else {
|
||||
$io->success(sprintf(
|
||||
'Anonymized %d persons from %s to %s',
|
||||
$identityCount,
|
||||
$inputFile,
|
||||
$targetFile,
|
||||
));
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function anonymizeDirectory(SymfonyStyle $io, string $inputDirectory, string $targetDirectory): int
|
||||
{
|
||||
if (false === is_readable($inputDirectory)) {
|
||||
$io->error(sprintf('Input directory is not readable: %s', $inputDirectory));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (true === file_exists($targetDirectory) && false === is_dir($targetDirectory)) {
|
||||
$io->error(sprintf('Output path is not a directory: %s', $targetDirectory));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (false === file_exists($targetDirectory) && false === mkdir($targetDirectory, 0777, true) && false === is_dir($targetDirectory)) {
|
||||
$io->error(sprintf('Unable to create output directory: %s', $targetDirectory));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$entries = scandir($inputDirectory);
|
||||
if (false === $entries) {
|
||||
$io->error(sprintf('Unable to list input directory: %s', $inputDirectory));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$processed = [];
|
||||
$fileCount = 0;
|
||||
$personCount = 0;
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ('.' === $entry || '..' === $entry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$inputPath = $inputDirectory.DIRECTORY_SEPARATOR.$entry;
|
||||
if (false === is_file($inputPath) || false === str_ends_with($entry, '.xml')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (true === isset($processed[$inputPath])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pairedInputPath = $this->derivePairedFilename($inputPath);
|
||||
if (null !== $pairedInputPath && true === is_file($pairedInputPath) && false === isset($processed[$pairedInputPath])) {
|
||||
$this->xmlAnonymizer->resetState();
|
||||
|
||||
$targetPath = $targetDirectory.DIRECTORY_SEPARATOR.$entry;
|
||||
$result = $this->anonymizeFile($io, $inputPath, $targetPath, false);
|
||||
if (Command::SUCCESS !== $result) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$processed[$inputPath] = true;
|
||||
++$fileCount;
|
||||
|
||||
$pairedEntry = basename($pairedInputPath);
|
||||
$pairedTargetPath = $targetDirectory.DIRECTORY_SEPARATOR.$pairedEntry;
|
||||
$result = $this->anonymizeFile($io, $pairedInputPath, $pairedTargetPath, true);
|
||||
if (Command::SUCCESS !== $result) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$processed[$pairedInputPath] = true;
|
||||
++$fileCount;
|
||||
$personCount += $this->xmlAnonymizer->getLastIdentityCount();
|
||||
|
||||
$io->note(sprintf('Paired file anonymized: %s -> %s', $pairedInputPath, $pairedTargetPath));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->xmlAnonymizer->resetState();
|
||||
|
||||
$targetPath = $targetDirectory.DIRECTORY_SEPARATOR.$entry;
|
||||
$result = $this->anonymizeFile($io, $inputPath, $targetPath, false);
|
||||
if (Command::SUCCESS !== $result) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$processed[$inputPath] = true;
|
||||
++$fileCount;
|
||||
$personCount += $this->xmlAnonymizer->getLastIdentityCount();
|
||||
}
|
||||
|
||||
$io->text(sprintf('Processed %d XML files', $fileCount));
|
||||
|
||||
if (0 === $personCount) {
|
||||
$io->warning(sprintf('No anonymizable person data found in %s', $inputDirectory));
|
||||
} else {
|
||||
$io->success(sprintf(
|
||||
'Anonymized %d persons from %s to %s',
|
||||
$personCount,
|
||||
$inputDirectory,
|
||||
$targetDirectory,
|
||||
));
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
@@ -10,11 +10,41 @@ use Faker\Generator;
|
||||
class BpnXmlAnonymizer
|
||||
{
|
||||
private const SAFE_EMAIL_DOMAIN = 'example.com';
|
||||
private const SALUTATIONS = ['Herr', 'Frau', 'Divers'];
|
||||
private const GENDERS = ['M', 'W', 'D'];
|
||||
private const COUNTRY_CODES = ['AT', 'BE', 'CH', 'DE', 'DK', 'ES', 'FR', 'IT', 'NL', 'NO', 'PL', 'SE'];
|
||||
|
||||
private Generator $faker;
|
||||
|
||||
/**
|
||||
* @var array<string, array{firstName: string, lastName: string, street: string, postalCode: string, city: string, email: string, mobilePhone: string}>
|
||||
* @var array<string, array{
|
||||
* firstName: string,
|
||||
* lastName: string,
|
||||
* salutation: string,
|
||||
* title: string,
|
||||
* gender: string,
|
||||
* nationality: string,
|
||||
* birthDate: string,
|
||||
* street: string,
|
||||
* postalCode: string,
|
||||
* city: string,
|
||||
* district: string,
|
||||
* country: string,
|
||||
* email: string,
|
||||
* mobilePhone: string,
|
||||
* phone: string,
|
||||
* newsletter: string,
|
||||
* iban: string,
|
||||
* userName: string,
|
||||
* requestKey: string,
|
||||
* requestPassword: string,
|
||||
* personId: string,
|
||||
* addressId: string,
|
||||
* customerId: string,
|
||||
* height: string,
|
||||
* weight: string,
|
||||
* shoeSize: string
|
||||
* }>
|
||||
*/
|
||||
private array $identities = [];
|
||||
|
||||
@@ -40,7 +70,7 @@ class BpnXmlAnonymizer
|
||||
}
|
||||
|
||||
$xpath = new \DOMXPath($document);
|
||||
$personNodes = $xpath->query('//anmelder | //teilnehmerliste/teilnehmer | //kundennamen/kunde');
|
||||
$personNodes = $xpath->query('//anfrage | //anmelder | //teilnehmerliste/teilnehmer | //kundennamen/kunde | //adressdaten');
|
||||
|
||||
if (false === $personNodes) {
|
||||
throw new \RuntimeException('Unable to query person nodes.');
|
||||
@@ -54,9 +84,17 @@ class BpnXmlAnonymizer
|
||||
$personId = $this->resolvePersonId($personNode, (int) $index);
|
||||
$identity = $this->getOrCreateIdentity($personId);
|
||||
|
||||
if ('anfrage' === $personNode->tagName) {
|
||||
$this->replaceRequestFields($personNode, $identity);
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->replaceNameFields($personNode, $identity);
|
||||
$this->replaceEmailFields($personNode, $identity);
|
||||
$this->replaceAddressFields($personNode, $identity);
|
||||
$this->replaceDemographicFields($personNode, $identity);
|
||||
$this->replaceFinancialFields($personNode, $identity);
|
||||
$this->replaceMetadataFields($personNode, $identity);
|
||||
}
|
||||
|
||||
$result = $document->saveXML();
|
||||
@@ -80,14 +118,47 @@ class BpnXmlAnonymizer
|
||||
|
||||
private function resolvePersonId(\DOMElement $personNode, int $nodeIndex): string
|
||||
{
|
||||
foreach (['personid', 'idperson', 'idadresseperson'] as $tagName) {
|
||||
$child = $this->getDirectChild($personNode, $tagName);
|
||||
if (null !== $child) {
|
||||
$value = trim($child->textContent);
|
||||
if ('' !== $value) {
|
||||
return $value;
|
||||
if ('anfrage' === $personNode->tagName || 'adressdaten' === $personNode->tagName) {
|
||||
$emailNode = $this->getDirectChild($personNode, 'email');
|
||||
if (null !== $emailNode) {
|
||||
$emailValue = trim($emailNode->textContent);
|
||||
if ('' !== $emailValue) {
|
||||
return sprintf('email:%s', mb_strtolower($emailValue));
|
||||
}
|
||||
}
|
||||
|
||||
if ('adressdaten' === $personNode->tagName) {
|
||||
$communicationNode = $this->getDirectChild($personNode, 'kommunikation');
|
||||
if (null !== $communicationNode) {
|
||||
$nestedEmailNode = $this->getDirectChild($communicationNode, 'email');
|
||||
if (null !== $nestedEmailNode) {
|
||||
$emailValue = trim($nestedEmailNode->textContent);
|
||||
if ('' !== $emailValue) {
|
||||
return sprintf('email:%s', mb_strtolower($emailValue));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$currentNode = $personNode;
|
||||
while ($currentNode instanceof \DOMElement) {
|
||||
foreach (['personid', 'idperson', 'idadresseperson'] as $tagName) {
|
||||
$child = $this->getDirectChild($currentNode, $tagName);
|
||||
if (null !== $child) {
|
||||
$value = trim($child->textContent);
|
||||
if ('' !== $value) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$parentNode = $currentNode->parentNode;
|
||||
if (false === $parentNode instanceof \DOMElement) {
|
||||
break;
|
||||
}
|
||||
|
||||
$currentNode = $parentNode;
|
||||
}
|
||||
|
||||
$fingerprintKey = $this->buildFingerprintKey($personNode);
|
||||
@@ -161,7 +232,34 @@ class BpnXmlAnonymizer
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{firstName: string, lastName: string, street: string, postalCode: string, city: string, email: string, mobilePhone: string}
|
||||
* @return array{
|
||||
* firstName: string,
|
||||
* lastName: string,
|
||||
* salutation: string,
|
||||
* title: string,
|
||||
* gender: string,
|
||||
* nationality: string,
|
||||
* birthDate: string,
|
||||
* street: string,
|
||||
* postalCode: string,
|
||||
* city: string,
|
||||
* district: string,
|
||||
* country: string,
|
||||
* email: string,
|
||||
* mobilePhone: string,
|
||||
* phone: string,
|
||||
* newsletter: string,
|
||||
* iban: string,
|
||||
* userName: string,
|
||||
* requestKey: string,
|
||||
* requestPassword: string,
|
||||
* personId: string,
|
||||
* addressId: string,
|
||||
* customerId: string,
|
||||
* height: string,
|
||||
* weight: string,
|
||||
* shoeSize: string
|
||||
* }
|
||||
*/
|
||||
private function getOrCreateIdentity(string $personId): array
|
||||
{
|
||||
@@ -169,25 +267,63 @@ class BpnXmlAnonymizer
|
||||
return $this->identities[$personId];
|
||||
}
|
||||
|
||||
$identityIndex = \count($this->identities) + 1;
|
||||
$firstName = $this->faker->firstName();
|
||||
$lastName = $this->faker->lastName();
|
||||
$salutation = 'Divers';
|
||||
$title = '';
|
||||
$gender = 'D';
|
||||
$nationality = 'XX';
|
||||
$birthDate = '01.01.1980';
|
||||
|
||||
$street = sprintf('%s %s', $this->faker->streetName(), $this->faker->buildingNumber());
|
||||
$postalCode = $this->faker->postcode();
|
||||
$city = $this->faker->city();
|
||||
$mobilePhone = $this->faker->numerify('01#########');
|
||||
$district = '';
|
||||
$country = 'XX';
|
||||
$mobilePhone = sprintf('01%09d', $identityIndex);
|
||||
$phone = sprintf('02%09d', $identityIndex);
|
||||
$newsletter = 'False';
|
||||
$iban = sprintf('DE%020d', $identityIndex);
|
||||
$userName = sprintf('anon-user-%d', $identityIndex);
|
||||
$requestKey = str_pad((string) $identityIndex, 32, '0', STR_PAD_LEFT);
|
||||
$requestPassword = str_pad((string) ($identityIndex + 1), 32, '0', STR_PAD_LEFT);
|
||||
$personIdValue = (string) (700000 + $identityIndex);
|
||||
$addressIdValue = (string) (800000 + $identityIndex);
|
||||
$customerIdValue = (string) (900000 + $identityIndex);
|
||||
$height = '170';
|
||||
$weight = '70';
|
||||
$shoeSize = '42';
|
||||
|
||||
$emailLocal = sprintf('%s.%s', $this->slug($firstName), $this->slug($lastName));
|
||||
$email = sprintf('%s@%s', $emailLocal, self::SAFE_EMAIL_DOMAIN);
|
||||
$email = sprintf('%s@%s', $userName, self::SAFE_EMAIL_DOMAIN);
|
||||
|
||||
$this->identities[$personId] = [
|
||||
'firstName' => $firstName,
|
||||
'lastName' => $lastName,
|
||||
'salutation' => $salutation,
|
||||
'title' => $title,
|
||||
'gender' => $gender,
|
||||
'nationality' => $nationality,
|
||||
'birthDate' => $birthDate,
|
||||
'street' => $street,
|
||||
'postalCode' => $postalCode,
|
||||
'city' => $city,
|
||||
'district' => $district,
|
||||
'country' => $country,
|
||||
'email' => strtolower($email),
|
||||
'mobilePhone' => $mobilePhone,
|
||||
'phone' => $phone,
|
||||
'newsletter' => $newsletter,
|
||||
'iban' => $iban,
|
||||
'userName' => $userName,
|
||||
'requestKey' => $requestKey,
|
||||
'requestPassword' => $requestPassword,
|
||||
'personId' => $personIdValue,
|
||||
'addressId' => $addressIdValue,
|
||||
'customerId' => $customerIdValue,
|
||||
'height' => $height,
|
||||
'weight' => $weight,
|
||||
'shoeSize' => $shoeSize,
|
||||
];
|
||||
|
||||
return $this->identities[$personId];
|
||||
@@ -215,6 +351,10 @@ class BpnXmlAnonymizer
|
||||
if (true === $personNode->hasAttribute('vorname')) {
|
||||
$personNode->setAttribute('vorname', $identity['firstName']);
|
||||
}
|
||||
|
||||
if (true === $personNode->hasAttribute('id') && 'kunde' === $personNode->tagName) {
|
||||
$personNode->setAttribute('id', $identity['customerId']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,6 +364,13 @@ class BpnXmlAnonymizer
|
||||
{
|
||||
$communicationNode = $this->getDirectChild($personNode, 'kommunikation');
|
||||
if (null === $communicationNode) {
|
||||
if ('anfrage' === $personNode->tagName) {
|
||||
$emailNode = $this->getDirectChild($personNode, 'email');
|
||||
if (null !== $emailNode) {
|
||||
$emailNode->nodeValue = $identity['email'];
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -236,6 +383,16 @@ class BpnXmlAnonymizer
|
||||
if (null !== $mobileNode) {
|
||||
$mobileNode->nodeValue = $identity['mobilePhone'];
|
||||
}
|
||||
|
||||
$phoneNode = $this->getDirectChild($communicationNode, 'telefonprivat');
|
||||
if (null !== $phoneNode) {
|
||||
$phoneNode->nodeValue = $identity['phone'];
|
||||
}
|
||||
|
||||
$newsletterNode = $this->getDirectChild($communicationNode, 'newsletter');
|
||||
if (null !== $newsletterNode) {
|
||||
$newsletterNode->nodeValue = $identity['newsletter'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -262,6 +419,130 @@ class BpnXmlAnonymizer
|
||||
if (null !== $cityNode) {
|
||||
$cityNode->nodeValue = $identity['city'];
|
||||
}
|
||||
|
||||
$districtNode = $this->getDirectChild($addressNode, 'ortsteil');
|
||||
if (null !== $districtNode) {
|
||||
$districtNode->nodeValue = $identity['district'];
|
||||
}
|
||||
|
||||
$countryNode = $this->getDirectChild($addressNode, 'land');
|
||||
if (null !== $countryNode) {
|
||||
$countryNode->nodeValue = $identity['country'];
|
||||
}
|
||||
|
||||
$addressIdNode = $this->getDirectChild($addressNode, 'id');
|
||||
if (null !== $addressIdNode) {
|
||||
$addressIdNode->nodeValue = $identity['addressId'];
|
||||
}
|
||||
}
|
||||
|
||||
private function replaceDemographicFields(\DOMElement $personNode, array $identity): void
|
||||
{
|
||||
$salutationNode = $this->getDirectChild($personNode, 'anrede');
|
||||
if (null !== $salutationNode) {
|
||||
$salutationNode->nodeValue = $identity['salutation'];
|
||||
}
|
||||
|
||||
$titleNode = $this->getDirectChild($personNode, 'titel');
|
||||
if (null !== $titleNode) {
|
||||
$titleNode->nodeValue = $identity['title'];
|
||||
}
|
||||
|
||||
$genderNode = $this->getDirectChild($personNode, 'geschlecht');
|
||||
if (null !== $genderNode) {
|
||||
$genderNode->nodeValue = $identity['gender'];
|
||||
}
|
||||
|
||||
$nationalityNode = $this->getDirectChild($personNode, 'nationalitaet');
|
||||
if (null !== $nationalityNode) {
|
||||
$nationalityNode->nodeValue = $identity['nationality'];
|
||||
}
|
||||
|
||||
$birthDateNode = $this->getDirectChild($personNode, 'geburtsdatum');
|
||||
if (null !== $birthDateNode) {
|
||||
$birthDateNode->nodeValue = $identity['birthDate'];
|
||||
}
|
||||
|
||||
$heightNode = $this->getDirectChild($personNode, 'sonstiges1');
|
||||
if (null !== $heightNode) {
|
||||
$heightNode->nodeValue = $identity['height'];
|
||||
}
|
||||
|
||||
$weightNode = $this->getDirectChild($personNode, 'sonstiges2');
|
||||
if (null !== $weightNode) {
|
||||
$weightNode->nodeValue = $identity['weight'];
|
||||
}
|
||||
|
||||
$shoeSizeNode = $this->getDirectChild($personNode, 'sonstiges3');
|
||||
if (null !== $shoeSizeNode) {
|
||||
$shoeSizeNode->nodeValue = $identity['shoeSize'];
|
||||
}
|
||||
}
|
||||
|
||||
private function replaceFinancialFields(\DOMElement $personNode, array $identity): void
|
||||
{
|
||||
$bankNode = $this->getDirectChild($personNode, 'bankverbindung');
|
||||
if (null === $bankNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ibanNode = $this->getDirectChild($bankNode, 'iban');
|
||||
if (null !== $ibanNode) {
|
||||
$ibanNode->nodeValue = $identity['iban'];
|
||||
}
|
||||
}
|
||||
|
||||
private function replaceMetadataFields(\DOMElement $personNode, array $identity): void
|
||||
{
|
||||
if (true === $personNode->hasAttribute('id')) {
|
||||
if ('kunde' === $personNode->tagName) {
|
||||
$personNode->setAttribute('id', $identity['customerId']);
|
||||
} elseif ('teilnehmer' === $personNode->tagName || 'anmelder' === $personNode->tagName) {
|
||||
$personNode->setAttribute('id', $identity['personId']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function replaceRequestFields(\DOMElement $personNode, array $identity): void
|
||||
{
|
||||
$userNode = $this->getDirectChild($personNode, 'user');
|
||||
if (null !== $userNode) {
|
||||
$userNode->nodeValue = $identity['userName'];
|
||||
}
|
||||
|
||||
$keyNode = $this->getDirectChild($personNode, 'key');
|
||||
if (null !== $keyNode) {
|
||||
$keyNode->nodeValue = $identity['requestKey'];
|
||||
}
|
||||
|
||||
$emailNode = $this->getDirectChild($personNode, 'email');
|
||||
if (null !== $emailNode) {
|
||||
$emailNode->nodeValue = $identity['email'];
|
||||
}
|
||||
|
||||
$passwordNode = $this->getDirectChild($personNode, 'passwort');
|
||||
if (null !== $passwordNode) {
|
||||
$passwordNode->nodeValue = $identity['requestPassword'];
|
||||
}
|
||||
}
|
||||
|
||||
private function findAncestorDirectChild(\DOMElement $personNode, string $ancestorTagName, string $childTagName): ?\DOMElement
|
||||
{
|
||||
$currentNode = $personNode;
|
||||
while ($currentNode instanceof \DOMElement) {
|
||||
if ($ancestorTagName === $currentNode->tagName) {
|
||||
return $this->getDirectChild($currentNode, $childTagName);
|
||||
}
|
||||
|
||||
$parentNode = $currentNode->parentNode;
|
||||
if (false === $parentNode instanceof \DOMElement) {
|
||||
break;
|
||||
}
|
||||
|
||||
$currentNode = $parentNode;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function getDirectChild(\DOMElement $parent, string $name): ?\DOMElement
|
||||
|
||||
Reference in New Issue
Block a user