Added Downloads to search index.

This commit is contained in:
orasche committed 2026-08-21 15:44:03 +02:00
1 parent 710f95794c
commit cf7a04ea15
6 files changed
+295 -22

No files matched your search

@@ -36,6 +36,7 @@ class SolrIndexCommand extends Command
$this->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Max queue items to index in this run', '20');
$this->addOption('root', 'r', InputOption::VALUE_REQUIRED, 'Root page uid of the site', '1');
$this->addOption('debug', 'd', InputOption::VALUE_NONE, 'Single-step the first queue item verbosely');
$this->addOption('initialize', 'i', InputOption::VALUE_REQUIRED, 'Initialize the index queue for these configurations (comma list or *) before indexing');
}
protected function execute(InputInterface $input, OutputInterface $output): int
@@ -68,7 +69,21 @@ class SolrIndexCommand extends Command
return Command::FAILURE;
}
$initialize = (string)($input->getOption('initialize') ?? '');
if ($initialize !== '') {
$names = $initialize === '*' ? ['*'] : array_map('trim', explode(',', $initialize));
$initService = GeneralUtility::makeInstance(\ApacheSolrForTypo3\Solr\Domain\Index\Queue\QueueInitializationService::class);
$result = $initService->initializeBySiteAndIndexConfigurations($site, $names);
foreach ($result as $name => $ok) {
$output->writeln(' initialized ' . $name . ': ' . var_export($ok, true));
}
}
if ($input->getOption('debug')) {
$queueConfig = $site->getSolrConfiguration()->getObjectByPathOrDefault('plugin.tx_solr.index.queue.');
$output->writeln('index.queue keys: ' . implode(', ', array_keys($queueConfig)));
$output->writeln('products.table: ' . var_export($queueConfig['products.']['table'] ?? null, true));
$queue = GeneralUtility::makeInstance(Queue::class);
$items = $queue->getItemsToIndex($site, 5);
$output->writeln('getItemsToIndex(5): ' . count($items) . ' items');
@@ -13,17 +13,21 @@ use TYPO3\CMS\Core\Http\Response;
use TYPO3\CMS\Core\Http\Stream;
/**
* Forced file download for download records: /download/file/<uid>
* Forced file download for download records, two routes:
*
* This is the frontend target of the "Download" record links from the link
* browser (config.recordLinks.download builds exactly this path). A direct
* fileadmin URL would open PDFs inline; this endpoint streams the file with
* Content-Disposition: attachment, so the browser saves it.
* /download/<slug> - canonical, human-readable (search results, sharing)
* /download/file/<uid> - legacy, kept because the "Download" record links
* from the link browser build exactly this path
* (config.recordLinks.download)
*
* A direct fileadmin URL would open PDFs inline; this endpoint streams the
* file with Content-Disposition: attachment, so the browser saves it.
*
* The file lookup lives in Service\DownloadFileResolver (FAL -> Collateral
* naming convention -> filepath column). Unknown uid, hidden record or
* missing file fall through to the regular pipeline - the reply is then the
* normal 404 page, never a broken download.
* naming convention -> filepath column), which also refuses records flagged
* private_download. Unknown uid/slug, hidden or private record or missing
* file fall through to the regular pipeline - the reply is then the normal
* 404 page, never a broken download.
*/
final class DownloadFileMiddleware implements MiddlewareInterface
{
@@ -31,21 +35,26 @@ final class DownloadFileMiddleware implements MiddlewareInterface
{
try {
$path = $request->getUri()->getPath();
$file = null;
if (preg_match('#^/download/file/(\\d+)/?$#', $path, $matches) === 1) {
$file = DownloadFileResolver::resolve((int)$matches[1]);
if ($file !== null) {
$filename = str_replace(['"', "\r", "\n"], '', $file['name']);
} elseif (preg_match('#^/download/([a-z0-9\\-]+)/?$#', $path, $matches) === 1) {
$file = DownloadFileResolver::resolveBySlug($matches[1]);
}
return new Response(
new Stream($file['path'], 'rb'),
200,
[
'Content-Type' => $file['mimeType'] !== '' ? $file['mimeType'] : 'application/octet-stream',
'Content-Length' => (string)$file['size'],
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
]
);
}
if ($file !== null) {
$filename = str_replace(['"', "\r", "\n"], '', $file['name']);
return new Response(
new Stream($file['path'], 'rb'),
200,
[
'Content-Type' => $file['mimeType'] !== '' ? $file['mimeType'] : 'application/octet-stream',
'Content-Length' => (string)$file['size'],
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
]
);
}
} catch (\Throwable $e) {
// fall through to the regular pipeline
@@ -51,6 +51,39 @@ final class DownloadFileResolver
}
}
/**
* Same contract as resolve(), addressed by the record slug - the pretty
* /download/<slug> route. Slugs are unique (eval: uniqueInPid).
*
* @return array{path: string, name: string, size: int, mimeType: string}|null
*/
public static function resolveBySlug(string $slug): ?array
{
if ($slug === '') {
return null;
}
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_download');
$uid = $qb
->select('uid')
->from('tx_vitec_domain_model_download')
->where(
$qb->expr()->eq('slug', $qb->createNamedParameter($slug, ParameterType::STRING)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->setMaxResults(1)
->executeQuery()
->fetchOne();
return $uid ? self::resolve((int)$uid) : null;
} catch (\Throwable $e) {
return null;
}
}
/** @return array<string,mixed>|null */
private static function loadDownload(int $uid): ?array
{
@@ -62,7 +95,10 @@ final class DownloadFileResolver
->where(
$qb->expr()->eq('uid', $qb->createNamedParameter($uid, ParameterType::INTEGER)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
$qb->expr()->eq('hidden', 0),
// private downloads are never served publicly - the caller
// cannot distinguish this from a missing file, by design
$qb->expr()->eq('private_download', 0)
)
->setMaxResults(1)
->executeQuery()
@@ -59,6 +59,19 @@ class SearchJsonRenderer
}
private const CTYPE = 'solr_pi_results';
/**
* Solr document types (index.queue config tables) to frontend-friendly
* type labels. Unknown types pass through verbatim.
*/
private const TYPE_LABELS = [
'pages' => 'page',
'tx_vitec_domain_model_product' => 'product',
'tx_vitec_domain_model_market' => 'market',
'tx_vitec_domain_model_usecase' => 'story',
'tx_news_domain_model_news' => 'news',
'tx_vitec_domain_model_download' => 'download',
];
private const TEASER_FALLBACK_LENGTH = 250;
#[AsAllowedCallable]
@@ -173,7 +186,7 @@ class SearchJsonRenderer
$results[] = [
'title' => (string)$document->getTitle(),
'url' => (string)$document->getUrl(),
'type' => (string)$document->getType(),
'type' => self::TYPE_LABELS[$document->getType()] ?? (string)$document->getType(),
'teaser' => $teaser,
];
}