feat: import buspro xml files from remote sftp source
This commit is contained in:
@@ -29,6 +29,7 @@
|
||||
"league/oauth2-client": "^2.8",
|
||||
"league/period": "^4.9",
|
||||
"lochmueller/staticfilecache": "^12.5",
|
||||
"phpseclib/phpseclib": "^3.0",
|
||||
"sjbr/sr-freecap": "2.6.0",
|
||||
"ssch/typo3-encore": "^3.0",
|
||||
"symfony/http-client": "^4.3",
|
||||
|
||||
Generated
+228
-1
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "f0ff2587673ce0da1d0d53cc382a0922",
|
||||
"content-hash": "0842a6e9476f96ae559c7d2e900c3be2",
|
||||
"packages": [
|
||||
{
|
||||
"name": "b13/container",
|
||||
@@ -2171,6 +2171,123 @@
|
||||
},
|
||||
"time": "2025-12-06T11:45:25+00:00"
|
||||
},
|
||||
{
|
||||
"name": "paragonie/constant_time_encoding",
|
||||
"version": "v2.8.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paragonie/constant_time_encoding.git",
|
||||
"reference": "e30811f7bc69e4b5b6d5783e712c06c8eabf0226"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/e30811f7bc69e4b5b6d5783e712c06c8eabf0226",
|
||||
"reference": "e30811f7bc69e4b5b6d5783e712c06c8eabf0226",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7|^8"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^6|^7|^8|^9",
|
||||
"vimeo/psalm": "^1|^2|^3|^4"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"ParagonIE\\ConstantTime\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Paragon Initiative Enterprises",
|
||||
"email": "[email protected]",
|
||||
"homepage": "https://paragonie.com",
|
||||
"role": "Maintainer"
|
||||
},
|
||||
{
|
||||
"name": "Steve 'Sc00bz' Thomas",
|
||||
"email": "[email protected]",
|
||||
"homepage": "https://www.tobtu.com",
|
||||
"role": "Original Developer"
|
||||
}
|
||||
],
|
||||
"description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
|
||||
"keywords": [
|
||||
"base16",
|
||||
"base32",
|
||||
"base32_decode",
|
||||
"base32_encode",
|
||||
"base64",
|
||||
"base64_decode",
|
||||
"base64_encode",
|
||||
"bin2hex",
|
||||
"encoding",
|
||||
"hex",
|
||||
"hex2bin",
|
||||
"rfc4648"
|
||||
],
|
||||
"support": {
|
||||
"email": "[email protected]",
|
||||
"issues": "https://github.com/paragonie/constant_time_encoding/issues",
|
||||
"source": "https://github.com/paragonie/constant_time_encoding"
|
||||
},
|
||||
"time": "2025-09-24T15:12:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "paragonie/random_compat",
|
||||
"version": "v9.99.100",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paragonie/random_compat.git",
|
||||
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a",
|
||||
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">= 7"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "4.*|5.*",
|
||||
"vimeo/psalm": "^1"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes."
|
||||
},
|
||||
"type": "library",
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Paragon Initiative Enterprises",
|
||||
"email": "[email protected]",
|
||||
"homepage": "https://paragonie.com"
|
||||
}
|
||||
],
|
||||
"description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7",
|
||||
"keywords": [
|
||||
"csprng",
|
||||
"polyfill",
|
||||
"pseudorandom",
|
||||
"random"
|
||||
],
|
||||
"support": {
|
||||
"email": "[email protected]",
|
||||
"issues": "https://github.com/paragonie/random_compat/issues",
|
||||
"source": "https://github.com/paragonie/random_compat"
|
||||
},
|
||||
"time": "2020-10-15T08:29:30+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpdocumentor/reflection-common",
|
||||
"version": "2.2.0",
|
||||
@@ -2346,6 +2463,116 @@
|
||||
},
|
||||
"time": "2025-11-21T15:09:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpseclib/phpseclib",
|
||||
"version": "3.0.57",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/phpseclib/phpseclib.git",
|
||||
"reference": "d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4",
|
||||
"reference": "d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"paragonie/constant_time_encoding": "^1|^2|^3",
|
||||
"paragonie/random_compat": "^1.4|^2.0|^9.99.99",
|
||||
"php": ">=5.6.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "*"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-dom": "Install the DOM extension to load XML formatted public keys.",
|
||||
"ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.",
|
||||
"ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.",
|
||||
"ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.",
|
||||
"ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"phpseclib/bootstrap.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"phpseclib3\\": "phpseclib/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jim Wigginton",
|
||||
"email": "[email protected]",
|
||||
"role": "Lead Developer"
|
||||
},
|
||||
{
|
||||
"name": "Patrick Monnerat",
|
||||
"email": "[email protected]",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Andreas Fischer",
|
||||
"email": "[email protected]",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Hans-Jürgen Petrich",
|
||||
"email": "[email protected]",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Graham Campbell",
|
||||
"email": "[email protected]",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.",
|
||||
"homepage": "http://phpseclib.sourceforge.net",
|
||||
"keywords": [
|
||||
"BigInteger",
|
||||
"aes",
|
||||
"asn.1",
|
||||
"asn1",
|
||||
"blowfish",
|
||||
"crypto",
|
||||
"cryptography",
|
||||
"encryption",
|
||||
"rsa",
|
||||
"security",
|
||||
"sftp",
|
||||
"signature",
|
||||
"signing",
|
||||
"ssh",
|
||||
"twofish",
|
||||
"x.509",
|
||||
"x509"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/phpseclib/phpseclib/issues",
|
||||
"source": "https://github.com/phpseclib/phpseclib/tree/3.0.57"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/terrafrost",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/phpseclib",
|
||||
"type": "patreon"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-26T12:13:21+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpdoc-parser",
|
||||
"version": "2.3.3",
|
||||
|
||||
@@ -28,10 +28,10 @@ namespace EP\EpProducts\Command;
|
||||
***************************************************************/
|
||||
|
||||
use EP\EpProducts\Service\DateImportService;
|
||||
use EP\EpProducts\Service\ProductImportSourceFactory;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface;
|
||||
use TYPO3\CMS\Extbase\Service\CacheService;
|
||||
|
||||
@@ -53,21 +53,29 @@ class DateCommandController extends Command
|
||||
*/
|
||||
protected $configurationManager;
|
||||
|
||||
/**
|
||||
* @var ProductImportSourceFactory
|
||||
*/
|
||||
protected $importSourceFactory;
|
||||
|
||||
/**
|
||||
* @param DateImportService $importService
|
||||
* @param CacheService $cacheService
|
||||
* @param ConfigurationManagerInterface $configurationManager
|
||||
* @param ProductImportSourceFactory $importSourceFactory
|
||||
*/
|
||||
public function __construct
|
||||
(
|
||||
DateImportService $importService,
|
||||
CacheService $cacheService,
|
||||
ConfigurationManagerInterface $configurationManager
|
||||
ConfigurationManagerInterface $configurationManager,
|
||||
ProductImportSourceFactory $importSourceFactory
|
||||
)
|
||||
{
|
||||
$this->dateImportService = $importService;
|
||||
$this->cacheService = $cacheService;
|
||||
$this->configurationManager = $configurationManager;
|
||||
$this->importSourceFactory = $importSourceFactory;
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -79,8 +87,15 @@ class DateCommandController extends Command
|
||||
*/
|
||||
public function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$path = GeneralUtility::getFileAbsFileName('fileadmin/xmlexport');
|
||||
$importSource = $this->importSourceFactory->get();
|
||||
$path = $importSource->acquire();
|
||||
if ($path === null) {
|
||||
$output->writeln('No new import available.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = $this->dateImportService->import($path);
|
||||
$importSource->markImported($path);
|
||||
$output->writeln(sprintf('%d rows imported.', $count ));
|
||||
$settings = $this->getSettings();
|
||||
$resellerPageUid = $settings['resellerExportPageUid'];
|
||||
|
||||
@@ -85,6 +85,11 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
|
||||
*/
|
||||
protected $cacheService;
|
||||
|
||||
/**
|
||||
* @var ProductImportSourceFactory
|
||||
*/
|
||||
protected $importSourceFactory;
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
@@ -95,10 +100,16 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
|
||||
/**
|
||||
* @param ConfigurationManagerInterface $configurationManager
|
||||
* @param CacheService $cacheService
|
||||
* @param ProductImportSourceFactory $importSourceFactory
|
||||
*/
|
||||
public function __construct(ConfigurationManagerInterface $configurationManager, CacheService $cacheService) {
|
||||
public function __construct(
|
||||
ConfigurationManagerInterface $configurationManager,
|
||||
CacheService $cacheService,
|
||||
ProductImportSourceFactory $importSourceFactory
|
||||
) {
|
||||
$this->configurationManager = $configurationManager;
|
||||
$this->cacheService = $cacheService;
|
||||
$this->importSourceFactory = $importSourceFactory;
|
||||
|
||||
$settings = GeneralUtility::removeDotsFromTS(
|
||||
$this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FULL_TYPOSCRIPT)
|
||||
@@ -121,7 +132,7 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
|
||||
$this->db = $this->getDbConnection();
|
||||
$this->getRoomMappings();
|
||||
$this->getHotelMappings();
|
||||
$this->importPickups();
|
||||
$this->importPickups($path);
|
||||
$this->createTempTables();
|
||||
$this->logger->info('starting product import');
|
||||
$dateCount = $this->parseXmlFilesForProducts($path);
|
||||
@@ -135,24 +146,7 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
|
||||
|
||||
public function isImportRequired(): bool
|
||||
{
|
||||
$timestampService = new ImportTimestampService();
|
||||
|
||||
return $timestampService->isImportRequired(
|
||||
'fileadmin/xmlexport/uebertragung.info',
|
||||
'fileadmin/products_import.info'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $path
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function checkActiveUpload($path): bool
|
||||
{
|
||||
$tempFile = $path . '/.pureftpd-upload.*';
|
||||
|
||||
return count(glob($tempFile)) > 0;
|
||||
return $this->importSourceFactory->get()->isNewImportAvailable();
|
||||
}
|
||||
|
||||
public function parseXmlFilesForProducts($path): int
|
||||
@@ -877,12 +871,13 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface
|
||||
}
|
||||
}
|
||||
|
||||
protected function importPickups(): void
|
||||
protected function importPickups(string $path): void
|
||||
{
|
||||
libxml_use_internal_errors (true);
|
||||
$path = GeneralUtility::getFileAbsFileName('fileadmin/xmlexport/zustiege.xml');
|
||||
if (!$xmlData = simplexml_load_string(file_get_contents($path))) {
|
||||
$file = $path . '/zustiege.xml';
|
||||
if (!is_file($file) || !$xmlData = simplexml_load_string((string) file_get_contents($file))) {
|
||||
libxml_clear_errors();
|
||||
$this->logger->warning('pickup xml missing or invalid', ['file' => $file]);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace EP\EpProducts\Service;
|
||||
|
||||
use TYPO3\CMS\Core\SingletonInterface;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* Legacy import source: the busProNet exporter drops the XML files straight into
|
||||
* fileadmin/xmlexport/ (historically via pure-ftpd on the server). Kept as an
|
||||
* opt-in fallback so the import can run without the SFTP pull — selected with the
|
||||
* `productImportSource = local` extension configuration switch.
|
||||
*/
|
||||
final class LocalImportSource implements ProductImportSource, SingletonInterface
|
||||
{
|
||||
private const XML_DIR = 'fileadmin/xmlexport';
|
||||
private const UPLOAD_MARKER = 'fileadmin/xmlexport/uebertragung.info';
|
||||
private const IMPORT_MARKER = 'fileadmin/products_import.info';
|
||||
|
||||
public function acquire(): ?string
|
||||
{
|
||||
if (!$this->isNewImportAvailable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return GeneralUtility::getFileAbsFileName(self::XML_DIR);
|
||||
}
|
||||
|
||||
public function markImported(string $path): void
|
||||
{
|
||||
(new ImportTimestampService())->writeImportTimestamp(self::IMPORT_MARKER);
|
||||
}
|
||||
|
||||
public function isNewImportAvailable(): bool
|
||||
{
|
||||
$timestampService = new ImportTimestampService();
|
||||
|
||||
$uploadMarkerPath = GeneralUtility::getFileAbsFileName(self::UPLOAD_MARKER);
|
||||
if ($uploadMarkerPath === '' || !is_file($uploadMarkerPath)) {
|
||||
// No supplier marker at all: import whatever is in the folder (legacy behaviour).
|
||||
return true;
|
||||
}
|
||||
|
||||
$uploadedAt = $timestampService->readImportTimestamp(self::UPLOAD_MARKER);
|
||||
$lastImportedAt = $timestampService->readImportTimestamp(self::IMPORT_MARKER);
|
||||
|
||||
return $uploadedAt === null || $lastImportedAt === null || $uploadedAt > $lastImportedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace EP\EpProducts\Service;
|
||||
|
||||
/**
|
||||
* A source of the busProNet product export (Ziel_*.xml, zustiege.xml,
|
||||
* uebertragung.info). Implementations either read a local drop directory or
|
||||
* pull the files from a remote SFTP server; the caller does not care which.
|
||||
*/
|
||||
interface ProductImportSource
|
||||
{
|
||||
/**
|
||||
* Returns a local directory ready to hand to {@see DateImportService::import()},
|
||||
* or null when there is nothing newer than the last successful import.
|
||||
*
|
||||
* @throws SftpImportException on an unrecoverable acquisition failure
|
||||
*/
|
||||
public function acquire(): ?string;
|
||||
|
||||
/**
|
||||
* Called once {@see DateImportService::import()} of the acquired path has
|
||||
* succeeded, so the source can advance its "last imported" bookkeeping.
|
||||
*/
|
||||
public function markImported(string $path): void;
|
||||
|
||||
/**
|
||||
* Best-effort, side-effect-free check for whether a newer export is available.
|
||||
*/
|
||||
public function isNewImportAvailable(): bool;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace EP\EpProducts\Service;
|
||||
|
||||
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
|
||||
use TYPO3\CMS\Core\SingletonInterface;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* Picks the product import source based on the `productImportSource` extension
|
||||
* configuration switch: `sftp` (default) pulls from the remote SFTP server,
|
||||
* `local` reads the legacy fileadmin/xmlexport/ drop directory.
|
||||
*/
|
||||
final class ProductImportSourceFactory implements SingletonInterface
|
||||
{
|
||||
public const MODE_SFTP = 'sftp';
|
||||
public const MODE_LOCAL = 'local';
|
||||
|
||||
public function get(): ProductImportSource
|
||||
{
|
||||
if ($this->getMode() === self::MODE_LOCAL) {
|
||||
return GeneralUtility::makeInstance(LocalImportSource::class);
|
||||
}
|
||||
|
||||
return GeneralUtility::makeInstance(SftpImportSource::class);
|
||||
}
|
||||
|
||||
public function getMode(): string
|
||||
{
|
||||
$config = GeneralUtility::makeInstance(ExtensionConfiguration::class)->get('ep_products');
|
||||
$mode = strtolower(trim((string)(is_array($config) ? ($config['productImportSource'] ?? '') : '')));
|
||||
|
||||
return $mode === self::MODE_LOCAL ? self::MODE_LOCAL : self::MODE_SFTP;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace EP\EpProducts\Service;
|
||||
|
||||
class SftpImportException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
<?php
|
||||
|
||||
namespace EP\EpProducts\Service;
|
||||
|
||||
use phpseclib3\Net\SFTP;
|
||||
use Psr\Log\LoggerAwareInterface;
|
||||
use Psr\Log\LoggerAwareTrait;
|
||||
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
|
||||
use TYPO3\CMS\Core\Core\Environment;
|
||||
use TYPO3\CMS\Core\SingletonInterface;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* Pulls the busProNet product export (Ziel_*.xml, zustiege.xml, uebertragung.info)
|
||||
* from a remote SFTP server into a local staging directory so the existing
|
||||
* {@see DateImportService::import()} can keep operating on a plain local folder.
|
||||
*
|
||||
* Change detection compares the remote uebertragung.info timestamp against the
|
||||
* staged copy of the last successfully imported export
|
||||
* (var/transient/ep_products_import/current/uebertragung.info) — not the
|
||||
* fileadmin/products_import.info marker, which is now display-only.
|
||||
*/
|
||||
final class SftpImportSource implements ProductImportSource, SingletonInterface, LoggerAwareInterface
|
||||
{
|
||||
use LoggerAwareTrait;
|
||||
|
||||
private const MARKER_FILE = 'uebertragung.info';
|
||||
private const PICKUPS_FILE = 'zustiege.xml';
|
||||
private const PRODUCT_PREFIX = 'Ziel';
|
||||
private const MARKER_TS_FORMAT = 'd.m.Y H:i:s';
|
||||
private const STAGING_SUBDIR = 'ep_products_import';
|
||||
private const PUBLISHED_DIR = 'current';
|
||||
|
||||
/** phpseclib rawlist() attribute value for a directory (NET_SFTP_TYPE_DIRECTORY). */
|
||||
private const REMOTE_TYPE_DIRECTORY = 2;
|
||||
|
||||
private const IMPORT_MARKER = 'fileadmin/products_import.info';
|
||||
|
||||
/**
|
||||
* @var array|null
|
||||
*/
|
||||
private $configuration;
|
||||
|
||||
/**
|
||||
* {@see ProductImportSource::acquire()} — alias for {@see fetchIfNewer()}.
|
||||
*/
|
||||
public function acquire(): ?string
|
||||
{
|
||||
return $this->fetchIfNewer();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@see ProductImportSource::markImported()} — publish the download as the new
|
||||
* change-detection baseline (only reached after a successful import), then
|
||||
* refresh the display-only marker for the BE module and system-info toolbar.
|
||||
*/
|
||||
public function markImported(string $path): void
|
||||
{
|
||||
$this->commit($path);
|
||||
(new ImportTimestampService())->writeImportTimestamp(self::IMPORT_MARKER);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@see ProductImportSource::isNewImportAvailable()} — alias for {@see isRemoteNewer()}.
|
||||
*/
|
||||
public function isNewImportAvailable(): bool
|
||||
{
|
||||
return $this->isRemoteNewer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Timestamp of the export currently offered on the remote server, or null when
|
||||
* the marker is absent/unreadable/unparseable (supplier upload not finished).
|
||||
*/
|
||||
public function getRemoteUploadTimestamp(): ?\DateTime
|
||||
{
|
||||
$config = $this->buildConfig();
|
||||
$sftp = $this->connect($config);
|
||||
try {
|
||||
[, $markerBytes] = $this->readRemoteMarker($sftp, $config['remotePath']);
|
||||
} finally {
|
||||
$sftp->disconnect();
|
||||
}
|
||||
|
||||
return $markerBytes !== null ? $this->parseMarkerTimestamp($markerBytes) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Timestamp of the export we last successfully imported (staged marker copy).
|
||||
*/
|
||||
public function getStagedImportedTimestamp(): ?\DateTime
|
||||
{
|
||||
$marker = $this->stagingBasePath() . '/' . self::PUBLISHED_DIR . '/' . self::MARKER_FILE;
|
||||
if (!is_file($marker)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->parseMarkerTimestamp((string)file_get_contents($marker));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the remote export is newer than the last imported one (or nothing
|
||||
* has been imported yet). False when there is nothing to do.
|
||||
*/
|
||||
public function isRemoteNewer(): bool
|
||||
{
|
||||
$remoteTs = $this->getRemoteUploadTimestamp();
|
||||
if ($remoteTs === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stagedTs = $this->getStagedImportedTimestamp();
|
||||
|
||||
return $stagedTs === null || $remoteTs > $stagedTs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Freshness check + (only when newer) full download into a fresh temp dir.
|
||||
* Returns the temp-dir path — NOT yet published. The caller runs
|
||||
* DateImportService::import() against it and calls {@see commit()} on success.
|
||||
* Returns null when the remote export is not newer / not ready.
|
||||
*
|
||||
* @throws SftpImportException on connect/auth/host-key/partial-transfer failure
|
||||
*/
|
||||
public function fetchIfNewer(): ?string
|
||||
{
|
||||
$this->cleanupStale();
|
||||
$config = $this->buildConfig();
|
||||
$sftp = $this->connect($config);
|
||||
|
||||
try {
|
||||
[$remoteDir, $markerBytes] = $this->readRemoteMarker($sftp, $config['remotePath']);
|
||||
$remoteTs = $markerBytes !== null ? $this->parseMarkerTimestamp($markerBytes) : null;
|
||||
|
||||
if ($remoteTs === null) {
|
||||
$this->logWarning('remote upload marker missing or unparseable; skipping product import', [
|
||||
'host' => $config['host'],
|
||||
'remotePath' => $config['remotePath'],
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$stagedTs = $this->getStagedImportedTimestamp();
|
||||
if ($stagedTs !== null && $remoteTs <= $stagedTs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->downloadInto($sftp, $remoteDir, $markerBytes);
|
||||
} finally {
|
||||
$sftp->disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unconditional download into a fresh temp dir. Returns the temp-dir path.
|
||||
*
|
||||
* @throws SftpImportException
|
||||
*/
|
||||
public function fetch(): string
|
||||
{
|
||||
$this->cleanupStale();
|
||||
$config = $this->buildConfig();
|
||||
$sftp = $this->connect($config);
|
||||
|
||||
try {
|
||||
[$remoteDir, $markerBytes] = $this->readRemoteMarker($sftp, $config['remotePath']);
|
||||
if ($markerBytes === null) {
|
||||
throw new SftpImportException(
|
||||
'Remote export has no readable ' . self::MARKER_FILE . ' under "' . $config['remotePath'] . '"'
|
||||
);
|
||||
}
|
||||
|
||||
return $this->downloadInto($sftp, $remoteDir, $markerBytes);
|
||||
} finally {
|
||||
$sftp->disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a temp dir produced by {@see fetchIfNewer()} / {@see fetch()} to
|
||||
* .../current, making its marker the new change-detection baseline. Call only
|
||||
* after import() succeeded.
|
||||
*
|
||||
* @throws SftpImportException
|
||||
*/
|
||||
public function commit(string $stagedPath): string
|
||||
{
|
||||
$base = $this->stagingBasePath();
|
||||
$stagedPath = rtrim($stagedPath, '/');
|
||||
|
||||
if (strpos($stagedPath . '/', $base . '/') !== 0 || !is_dir($stagedPath)) {
|
||||
throw new SftpImportException('Refusing to publish unknown staging directory: ' . $stagedPath);
|
||||
}
|
||||
|
||||
$published = $base . '/' . self::PUBLISHED_DIR;
|
||||
|
||||
if (is_dir($published)) {
|
||||
$retired = $published . '.old-' . time() . '-' . bin2hex(random_bytes(3));
|
||||
if (!@rename($published, $retired)) {
|
||||
throw new SftpImportException('Cannot move previous import aside: ' . $published);
|
||||
}
|
||||
}
|
||||
|
||||
if (!@rename($stagedPath, $published)) {
|
||||
throw new SftpImportException('Cannot publish staging directory to ' . $published);
|
||||
}
|
||||
|
||||
foreach ((array)glob($base . '/' . self::PUBLISHED_DIR . '.old-*', GLOB_ONLYDIR) as $old) {
|
||||
GeneralUtility::rmdir($old, true);
|
||||
}
|
||||
|
||||
return $published;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{host:string,port:int,user:string,password:string,remotePath:string,timeout:int,hostKey:string} $config
|
||||
*/
|
||||
private function connect(array $config): SFTP
|
||||
{
|
||||
try {
|
||||
$sftp = new SFTP($config['host'], $config['port'], $config['timeout']);
|
||||
} catch (\Throwable $e) {
|
||||
throw new SftpImportException(
|
||||
sprintf('Cannot connect to SFTP host %s:%d: %s', $config['host'], $config['port'], $e->getMessage()),
|
||||
0,
|
||||
$e
|
||||
);
|
||||
}
|
||||
|
||||
if ($config['hostKey'] !== '') {
|
||||
$this->verifyHostKey($sftp, $config['host'], $config['hostKey']);
|
||||
}
|
||||
|
||||
try {
|
||||
$authenticated = $sftp->login($config['user'], $config['password']);
|
||||
} catch (\Throwable $e) {
|
||||
throw new SftpImportException(
|
||||
sprintf('Cannot connect to SFTP host %s:%d: %s', $config['host'], $config['port'], $e->getMessage()),
|
||||
0,
|
||||
$e
|
||||
);
|
||||
}
|
||||
|
||||
if ($authenticated !== true) {
|
||||
throw new SftpImportException(sprintf('SFTP authentication failed for user "%s"', $config['user']));
|
||||
}
|
||||
|
||||
return $sftp;
|
||||
}
|
||||
|
||||
private function verifyHostKey(SFTP $sftp, string $host, string $expected): void
|
||||
{
|
||||
try {
|
||||
$actual = $sftp->getServerPublicHostKey();
|
||||
} catch (\Throwable $e) {
|
||||
throw new SftpImportException('Cannot read SSH host key for ' . $host . ': ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
|
||||
if ($actual === false) {
|
||||
throw new SftpImportException('Cannot read SSH host key for ' . $host);
|
||||
}
|
||||
|
||||
$expected = trim($expected);
|
||||
$actual = trim($actual);
|
||||
|
||||
if (hash_equals($actual, $expected)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$parts = explode(' ', $actual);
|
||||
$rawKey = isset($parts[1]) ? base64_decode($parts[1], true) : false;
|
||||
if ($rawKey !== false) {
|
||||
$fingerprint = 'SHA256:' . rtrim(base64_encode(hash('sha256', $rawKey, true)), '=');
|
||||
if (hash_equals($fingerprint, $expected)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new SftpImportException('SSH host key mismatch for ' . $host . ' — refusing to connect');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the upload marker, probing each acceptable form of the configured
|
||||
* remote directory. Some SFTP servers (e.g. Hetzner Storage Box) only accept
|
||||
* paths relative to the login directory and silently fail on a leading slash.
|
||||
*
|
||||
* @return array{0:string,1:?string} the directory form that worked, and the marker bytes (null if none did)
|
||||
*/
|
||||
private function readRemoteMarker(SFTP $sftp, string $configuredPath): array
|
||||
{
|
||||
$candidates = $this->remoteDirCandidates($configuredPath);
|
||||
|
||||
foreach ($candidates as $dir) {
|
||||
try {
|
||||
$contents = $sftp->get($this->joinRemote($dir, self::MARKER_FILE));
|
||||
} catch (\Throwable $e) {
|
||||
throw new SftpImportException(
|
||||
'Failed to read remote ' . self::MARKER_FILE . ': ' . $e->getMessage(),
|
||||
0,
|
||||
$e
|
||||
);
|
||||
}
|
||||
|
||||
if ($contents !== false) {
|
||||
return [$dir, (string)$contents];
|
||||
}
|
||||
}
|
||||
|
||||
return [$candidates[0], null];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[] non-empty, unique
|
||||
*/
|
||||
private function remoteDirCandidates(string $configuredPath): array
|
||||
{
|
||||
$configured = trim($configuredPath);
|
||||
$relative = ltrim($configured, '/');
|
||||
|
||||
$candidates = [];
|
||||
if ($configured !== '' && $configured !== '/') {
|
||||
$candidates[] = $configured;
|
||||
}
|
||||
if ($relative !== '' && $relative !== $configured) {
|
||||
$candidates[] = $relative;
|
||||
}
|
||||
$candidates[] = '.';
|
||||
|
||||
return array_values(array_unique($candidates));
|
||||
}
|
||||
|
||||
/**
|
||||
* Download Ziel_*.xml + zustiege.xml + uebertragung.info into a fresh temp dir,
|
||||
* verifying each file's size against the remote listing. The marker is written
|
||||
* last, from the bytes already fetched for the freshness check.
|
||||
*
|
||||
* @throws SftpImportException
|
||||
*/
|
||||
private function downloadInto(SFTP $sftp, string $remotePath, string $markerBytes): string
|
||||
{
|
||||
$list = $sftp->rawlist($remotePath, false);
|
||||
if ($list === false) {
|
||||
throw new SftpImportException('Cannot list remote directory ' . $remotePath);
|
||||
}
|
||||
|
||||
$productFiles = [];
|
||||
$pickupSize = null;
|
||||
foreach ($list as $name => $attrs) {
|
||||
if ($name === '.' || $name === '..' || !is_array($attrs)) {
|
||||
continue;
|
||||
}
|
||||
if ((int)($attrs['type'] ?? 0) === self::REMOTE_TYPE_DIRECTORY) {
|
||||
continue;
|
||||
}
|
||||
if (strpos($name, self::PRODUCT_PREFIX) === 0 && substr($name, -4) === '.xml') {
|
||||
$productFiles[$name] = (int)($attrs['size'] ?? -1);
|
||||
} elseif ($name === self::PICKUPS_FILE) {
|
||||
$pickupSize = (int)($attrs['size'] ?? -1);
|
||||
}
|
||||
}
|
||||
|
||||
if (count($productFiles) === 0) {
|
||||
throw new SftpImportException('Remote export contains no ' . self::PRODUCT_PREFIX . '_*.xml files');
|
||||
}
|
||||
|
||||
$previousCount = $this->stagedProductFileCount();
|
||||
if ($previousCount > 0 && count($productFiles) < (int)floor($previousCount * 0.5)) {
|
||||
throw new SftpImportException(sprintf(
|
||||
'Remote export looks truncated (%d %s_*.xml files vs %d previously) — refusing to import',
|
||||
count($productFiles),
|
||||
self::PRODUCT_PREFIX,
|
||||
$previousCount
|
||||
));
|
||||
}
|
||||
|
||||
$toDownload = $productFiles;
|
||||
if ($pickupSize !== null) {
|
||||
$toDownload[self::PICKUPS_FILE] = $pickupSize;
|
||||
} else {
|
||||
$this->logWarning('remote export has no ' . self::PICKUPS_FILE . '; pickups will be skipped');
|
||||
}
|
||||
|
||||
$tmp = $this->newTempDir();
|
||||
|
||||
foreach ($toDownload as $name => $expectedSize) {
|
||||
$target = $tmp . '/' . $name;
|
||||
try {
|
||||
$ok = $sftp->get($this->joinRemote($remotePath, $name), $target);
|
||||
} catch (\Throwable $e) {
|
||||
GeneralUtility::rmdir($tmp, true);
|
||||
throw new SftpImportException('Download failed for ' . $name . ': ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
|
||||
if ($ok === false || !is_file($target)) {
|
||||
GeneralUtility::rmdir($tmp, true);
|
||||
throw new SftpImportException('Download failed for ' . $name);
|
||||
}
|
||||
|
||||
clearstatcache(true, $target);
|
||||
if ($expectedSize >= 0 && filesize($target) !== $expectedSize) {
|
||||
$actualSize = filesize($target);
|
||||
GeneralUtility::rmdir($tmp, true);
|
||||
throw new SftpImportException(sprintf(
|
||||
'Size mismatch for %s (got %d bytes, expected %d)',
|
||||
$name,
|
||||
$actualSize,
|
||||
$expectedSize
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Write the completion marker last, mirroring the supplier's upload order.
|
||||
GeneralUtility::writeFile($tmp . '/' . self::MARKER_FILE, $markerBytes, false);
|
||||
|
||||
return $tmp;
|
||||
}
|
||||
|
||||
private function stagedProductFileCount(): int
|
||||
{
|
||||
$dir = $this->stagingBasePath() . '/' . self::PUBLISHED_DIR;
|
||||
if (!is_dir($dir)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ((array)scandir($dir) as $name) {
|
||||
if (strpos((string)$name, self::PRODUCT_PREFIX) === 0 && substr((string)$name, -4) === '.xml') {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function parseMarkerTimestamp(string $contents): ?\DateTime
|
||||
{
|
||||
$line = trim((string)strtok($contents, "\r\n"));
|
||||
if ($line === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$date = \DateTime::createFromFormat(self::MARKER_TS_FORMAT, $line);
|
||||
|
||||
return $date instanceof \DateTime ? $date : null;
|
||||
}
|
||||
|
||||
private function logWarning(string $message, array $context = []): void
|
||||
{
|
||||
if ($this->logger !== null) {
|
||||
$this->logger->warning($message, $context);
|
||||
}
|
||||
}
|
||||
|
||||
private function stagingBasePath(): string
|
||||
{
|
||||
return Environment::getVarPath() . '/transient/' . self::STAGING_SUBDIR;
|
||||
}
|
||||
|
||||
private function newTempDir(): string
|
||||
{
|
||||
$dir = $this->stagingBasePath() . '/.tmp-' . getmypid() . '-' . bin2hex(random_bytes(4));
|
||||
GeneralUtility::mkdir_deep($dir);
|
||||
if (!is_dir($dir) || !is_writable($dir)) {
|
||||
throw new SftpImportException('Cannot create staging directory: ' . $dir);
|
||||
}
|
||||
|
||||
return $dir;
|
||||
}
|
||||
|
||||
private function cleanupStale(): void
|
||||
{
|
||||
$base = $this->stagingBasePath();
|
||||
if (!is_dir($base)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$stale = array_merge(
|
||||
(array)glob($base . '/.tmp-*', GLOB_ONLYDIR),
|
||||
(array)glob($base . '/' . self::PUBLISHED_DIR . '.old-*', GLOB_ONLYDIR)
|
||||
);
|
||||
foreach ($stale as $dir) {
|
||||
GeneralUtility::rmdir($dir, true);
|
||||
}
|
||||
}
|
||||
|
||||
private function joinRemote(string $base, string $name): string
|
||||
{
|
||||
return rtrim($base, '/') . '/' . ltrim($name, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{host:string,port:int,user:string,password:string,remotePath:string,timeout:int,hostKey:string}
|
||||
*/
|
||||
private function buildConfig(): array
|
||||
{
|
||||
if ($this->configuration === null) {
|
||||
$raw = GeneralUtility::makeInstance(ExtensionConfiguration::class)->get('ep_products');
|
||||
$raw = is_array($raw) ? $raw : [];
|
||||
|
||||
$host = trim((string)($raw['productImportSftpHost'] ?? ''));
|
||||
$user = trim((string)($raw['productImportSftpUser'] ?? ''));
|
||||
$password = (string)($raw['productImportSftpPassword'] ?? '');
|
||||
$remotePath = trim((string)($raw['productImportSftpRemotePath'] ?? '/'));
|
||||
$port = (int)($raw['productImportSftpPort'] ?? 22);
|
||||
$timeout = (int)($raw['productImportSftpTimeout'] ?? 15);
|
||||
$hostKey = trim((string)($raw['productImportSftpHostKey'] ?? ''));
|
||||
|
||||
if ($host === '' || $user === '') {
|
||||
throw new SftpImportException(
|
||||
'SFTP product import is not configured: host and username are required '
|
||||
. '(Admin Tools → Settings → Extension Configuration → ep_products).'
|
||||
);
|
||||
}
|
||||
if ($password === '') {
|
||||
throw new SftpImportException('SFTP product import is not configured: password is required.');
|
||||
}
|
||||
|
||||
$this->configuration = [
|
||||
'host' => $host,
|
||||
'port' => $port > 0 ? $port : 22,
|
||||
'user' => $user,
|
||||
'password' => $password,
|
||||
'remotePath' => $remotePath === '' ? '/' : $remotePath,
|
||||
'timeout' => $timeout > 0 ? $timeout : 15,
|
||||
'hostKey' => $hostKey,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->configuration;
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ namespace EP\EpProducts\Task;
|
||||
***************************************************************/
|
||||
|
||||
use EP\EpProducts\Service\DateImportService;
|
||||
use EP\EpProducts\Service\ProductImportSourceFactory;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
use TYPO3\CMS\Extbase\Configuration\ConfigurationManager;
|
||||
use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface;
|
||||
@@ -39,11 +40,20 @@ class ImportDatesTask extends AbstractTask
|
||||
public function execute(): bool
|
||||
{
|
||||
$importService = GeneralUtility::makeInstance(DateImportService::class);
|
||||
$importSource = GeneralUtility::makeInstance(ProductImportSourceFactory::class)->get();
|
||||
$cacheService = GeneralUtility::makeInstance(CacheService::class);
|
||||
|
||||
if ($importService->isImportRequired()) {
|
||||
$path = GeneralUtility::getFileAbsFileName('fileadmin/xmlexport');
|
||||
// Acquire the export only when it is newer than the one we last imported
|
||||
// (SFTP mode: download to a staging dir; local mode: fileadmin/xmlexport).
|
||||
// An acquisition failure (SFTP connect/auth/transfer) is left to propagate
|
||||
// so the scheduler marks the task failed.
|
||||
$path = $importSource->acquire();
|
||||
|
||||
if ($path !== null) {
|
||||
$importService->import($path);
|
||||
// Advance the "last imported" bookkeeping only after a successful
|
||||
// import, so a crashed import retries on the next run.
|
||||
$importSource->markImported($path);
|
||||
}
|
||||
|
||||
$settings = $this->getSettings();
|
||||
|
||||
@@ -39,3 +39,27 @@ bpnConnectApiTimeout = 5
|
||||
|
||||
# cat=MyEpAPI; type=int; label=Bpn Connect API max total request duration in seconds (0 = unlimited)
|
||||
bpnConnectApiMaxDuration = 15
|
||||
|
||||
# cat=SftpImport; type=options[Remote SFTP server=sftp,Local directory (fileadmin/xmlexport)=local]; label=Product/date import source
|
||||
productImportSource = sftp
|
||||
|
||||
# cat=SftpImport; type=string; label=SFTP host for product/date import
|
||||
productImportSftpHost =
|
||||
|
||||
# cat=SftpImport; type=int+; label=SFTP port
|
||||
productImportSftpPort = 22
|
||||
|
||||
# cat=SftpImport; type=string; label=SFTP username
|
||||
productImportSftpUser =
|
||||
|
||||
# cat=SftpImport; type=string; label=SFTP password
|
||||
productImportSftpPassword =
|
||||
|
||||
# cat=SftpImport; type=string; label=Remote directory holding Ziel_*.xml, zustiege.xml and uebertragung.info
|
||||
productImportSftpRemotePath = /
|
||||
|
||||
# cat=SftpImport; type=string; label=Expected SSH host key (optional; "ssh-ed25519 AAAA..." or "SHA256:..."; empty disables the check)
|
||||
productImportSftpHostKey =
|
||||
|
||||
# cat=SftpImport; type=int+; label=SFTP connection timeout in seconds
|
||||
productImportSftpTimeout = 15
|
||||
|
||||
Reference in New Issue
Block a user