Files
VITEC-website/packages/vitec/Classes/Controller/Backend/SubmarketImportController.php
T
orasche 3d5459b8d2 Sub-market workbook import as a backend module tab
- Extract the complete import logic (label-scan reading, page
  find-or-create, slot filling, content-idempotent image attach) from
  ImportSubmarketWorkbooksCommand into the shared service
  SubmarketWorkbookImporter; the CLI command and the new controller are
  thin shells around importFile(), so both entry points cannot drift.
  CLI regression verified: identical dry-run report for the reference
  workbook (12 elements, page 443)
- New "Sub-Markets" tab in the VITEC Import module
  (SubmarketImportController + Import/Submarkets.html): upload multiple
  filled workbooks into the SAME inbox the CLI uses
  (migrations/submarkets_xlsx_in/), per-file checkboxes, dry-run and
  apply buttons, result cards with the familiar report and a direct
  link into the page module; backend dry-run verified against the CLI
- Submitted filenames are whitelisted against the actual directory
  listing (no path traversal); uploads restricted to sanitized .xlsx
  basenames
- Routes submarkets/submarket_upload/submarket_process in Modules.php,
  Sub-Markets tab link in the sibling module views, ok/danger badge
  colors in backend-import.css
- Docs section 5y (incl. operations note: apply in small batches in the
  module, mass runs via CLI - image downloads run inside the web
  request)
2026-10-05 15:35:40 +02:00

182 lines
7.1 KiB
PHP

<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Controller\Backend;
use Evomedien\Vitec\Import\SubmarketWorkbookImporter;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Sub-Markets tab of the "VITEC Import" module: upload the customer-filled
* sub-market workbooks (Joe's XLSX files), dry-run them with the same report
* the CLI prints, then apply per file or all at once.
*
* Files live in migrations/submarkets_xlsx_in/ - the SAME directory the CLI
* uses, so both entry points always see the same inbox. The import itself is
* SubmarketWorkbookImporter (shared with vitec:import-submarket-workbooks);
* this controller only lists, uploads and renders results.
*/
#[AsController]
final class SubmarketImportController
{
private const INBOX = 'migrations/submarkets_xlsx_in';
public function __construct(
private readonly ModuleTemplateFactory $moduleTemplateFactory,
private readonly SubmarketWorkbookImporter $importer,
private readonly FlashMessageService $flashMessageService,
private readonly PageRenderer $pageRenderer,
private readonly UriBuilder $uriBuilder,
) {}
public function submarketsAction(ServerRequestInterface $request): ResponseInterface
{
return $this->render($request, []);
}
public function submarketUploadAction(ServerRequestInterface $request): ResponseInterface
{
$uploads = $request->getUploadedFiles()['workbooks'] ?? [];
if (!is_array($uploads)) {
$uploads = [$uploads];
}
$dir = $this->inboxPath();
if (!is_dir($dir)) {
GeneralUtility::mkdir_deep($dir);
}
$stored = [];
foreach ($uploads as $upload) {
if ($upload === null || $upload->getError() !== UPLOAD_ERR_OK) {
continue;
}
$name = $this->safeName((string)$upload->getClientFilename());
if ($name === '') {
$this->message('Skipped a file that is not an .xlsx workbook.', ContextualFeedbackSeverity::WARNING);
continue;
}
$upload->moveTo($dir . '/' . $name);
GeneralUtility::fixPermissions($dir . '/' . $name);
$stored[] = $name;
}
if ($stored !== []) {
$this->message(count($stored) . ' workbook(s) stored: ' . implode(', ', $stored), ContextualFeedbackSeverity::OK);
} else {
$this->message('No workbook uploaded.', ContextualFeedbackSeverity::WARNING);
}
return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecimport.submarkets'), 303);
}
public function submarketProcessAction(ServerRequestInterface $request): ResponseInterface
{
$body = (array)$request->getParsedBody();
$apply = (string)($body['op'] ?? 'dry') === 'apply';
$selected = array_map('strval', (array)($body['files'] ?? []));
if ($selected === []) {
$this->message('No workbook selected.', ContextualFeedbackSeverity::WARNING);
return $this->render($request, []);
}
$available = array_flip(array_column($this->workbooks(), 'name'));
$results = [];
foreach ($selected as $name) {
// Whitelist against the actual directory listing - the submitted
// name never touches the filesystem on its own (no traversal).
if (!isset($available[$name])) {
$results[] = ['file' => $name, 'error' => 'File not found in the inbox.'];
continue;
}
try {
$results[] = $this->importer->importFile($this->inboxPath() . '/' . $name, $apply);
} catch (\Throwable $e) {
$results[] = ['file' => $name, 'error' => $e->getMessage()];
}
}
$errors = count(array_filter($results, static fn(array $r): bool => isset($r['error'])));
$this->message(
sprintf('%d workbook(s) processed (%s)%s.',
count($results),
$apply ? 'APPLY' : 'dry run',
$errors > 0 ? ', ' . $errors . ' with errors' : ''
),
$errors > 0 ? ContextualFeedbackSeverity::WARNING : ContextualFeedbackSeverity::OK
);
return $this->render($request, $results);
}
// ------------------------------------------------------------ internals
/** @param array<int,array<string,mixed>> $results */
private function render(ServerRequestInterface $request, array $results): ResponseInterface
{
$view = $this->moduleTemplateFactory->create($request);
$this->pageRenderer->addCssFile('EXT:vitec/Resources/Public/Css/backend-import.css');
$view->assignMultiple([
'workbooks' => $this->workbooks(),
'inbox' => self::INBOX,
'results' => $results,
'hasResults' => $results !== [],
]);
return $view->renderResponse('Import/Submarkets');
}
/** @return array<int,array{name:string,size:string,modified:string}> */
private function workbooks(): array
{
$dir = $this->inboxPath();
if (!is_dir($dir)) {
return [];
}
$rows = [];
foreach (scandir($dir) ?: [] as $name) {
if (!str_ends_with($name, '.xlsx') || str_starts_with($name, '~$')) {
continue;
}
$path = $dir . '/' . $name;
if (!is_file($path)) {
continue;
}
$rows[] = [
'name' => $name,
'size' => sprintf('%.0f KB', filesize($path) / 1024),
'modified' => date('Y-m-d H:i', (int)filemtime($path)),
];
}
usort($rows, static fn(array $a, array $b): int => strcasecmp($a['name'], $b['name']));
return $rows;
}
private function inboxPath(): string
{
return Environment::getProjectPath() . '/' . self::INBOX;
}
/** Client filename reduced to a safe basename; '' when it is no .xlsx. */
private function safeName(string $clientFilename): string
{
$name = basename(str_replace('\\', '/', trim($clientFilename)));
if (!str_ends_with(mb_strtolower($name), '.xlsx') || str_starts_with($name, '~$') || str_starts_with($name, '.')) {
return '';
}
return (string)preg_replace('/[^\w.\-() ]+/u', '_', $name);
}
private function message(string $text, ContextualFeedbackSeverity $severity): void
{
$message = GeneralUtility::makeInstance(FlashMessage::class, $text, '', $severity, true);
$this->flashMessageService->getMessageQueueByIdentifier()->addMessage($message);
}
}