263 lines
9.0 KiB
PHP
263 lines
9.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Evomedien\Vitec\Service;
|
|
|
|
use Doctrine\DBAL\ParameterType;
|
|
use TYPO3\CMS\Core\Core\Environment;
|
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|
|
|
/**
|
|
* Resolves a download record to its file on disk.
|
|
*
|
|
* Resolution order is the one the three download renderers use for their
|
|
* `file` payload - FAL reference, then the Collateral naming convention,
|
|
* then the filepath column - so a record link and the JSON always point at
|
|
* the same file. The renderers still carry private copies of this logic for
|
|
* their richer payload (thumbnail, title, url); consolidating them onto this
|
|
* service is the standing 10.2 goal (Annex B-4).
|
|
*
|
|
* Contract: null means "no file". Never throws.
|
|
*/
|
|
final class DownloadFileResolver
|
|
{
|
|
/**
|
|
* @return array{path: string, name: string, size: int, mimeType: string}|null
|
|
*/
|
|
public static function resolve(int $downloadUid): ?array
|
|
{
|
|
if ($downloadUid <= 0) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$download = self::loadDownload($downloadUid);
|
|
if ($download === null) {
|
|
return null;
|
|
}
|
|
|
|
$path = self::resolveByFal($downloadUid)
|
|
?? self::resolveByConvention(
|
|
(string)($download['fileprefix'] ?? ''),
|
|
self::fileType($downloadUid)
|
|
)
|
|
?? self::resolveByFilepath((string)($download['filepath'] ?? ''));
|
|
|
|
return $path !== null ? self::describe($path) : null;
|
|
} catch (\Throwable $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
{
|
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
|
->getQueryBuilderForTable('tx_vitec_domain_model_download');
|
|
$row = $qb
|
|
->select('uid', 'fileprefix', 'filepath')
|
|
->from('tx_vitec_domain_model_download')
|
|
->where(
|
|
$qb->expr()->eq('uid', $qb->createNamedParameter($uid, ParameterType::INTEGER)),
|
|
$qb->expr()->eq('deleted', 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()
|
|
->fetchAssociative();
|
|
|
|
return $row ?: null;
|
|
}
|
|
|
|
private static function resolveByFal(int $downloadUid): ?string
|
|
{
|
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
|
->getQueryBuilderForTable('sys_file_reference');
|
|
$row = $qb
|
|
->select('f.identifier')
|
|
->from('sys_file_reference', 'fr')
|
|
->join('fr', 'sys_file', 'f', 'fr.uid_local = f.uid')
|
|
->where(
|
|
$qb->expr()->eq('fr.tablenames', $qb->createNamedParameter('tx_vitec_domain_model_download', ParameterType::STRING)),
|
|
$qb->expr()->eq('fr.fieldname', $qb->createNamedParameter('file', ParameterType::STRING)),
|
|
$qb->expr()->eq('fr.uid_foreign', $qb->createNamedParameter($downloadUid, ParameterType::INTEGER)),
|
|
$qb->expr()->eq('fr.deleted', 0),
|
|
$qb->expr()->eq('f.missing', 0)
|
|
)
|
|
->orderBy('fr.sorting_foreign', 'ASC')
|
|
->setMaxResults(1)
|
|
->executeQuery()
|
|
->fetchAssociative();
|
|
|
|
$identifier = (string)($row['identifier'] ?? '');
|
|
if ($identifier === '') {
|
|
return null;
|
|
}
|
|
|
|
$path = Environment::getPublicPath() . '/fileadmin' . $identifier;
|
|
|
|
return is_file($path) && is_readable($path) ? $path : null;
|
|
}
|
|
|
|
/**
|
|
* Highest revision of <prefix>__<filetype>__<NN>-<letters>.<ext> in the
|
|
* Collateral directory - same pattern and ranking as the renderers.
|
|
*/
|
|
private static function resolveByConvention(string $fileprefix, string $filetype): ?string
|
|
{
|
|
$fileprefix = trim($fileprefix);
|
|
$filetype = trim($filetype);
|
|
if ($fileprefix === '' || $filetype === '') {
|
|
return null;
|
|
}
|
|
|
|
$baseDir = Environment::getPublicPath() . '/fileadmin/downloads/Collateral';
|
|
if (!is_dir($baseDir) || !is_readable($baseDir)) {
|
|
return null;
|
|
}
|
|
|
|
$pattern = '/^' . preg_quote($fileprefix, '/') . '__' . preg_quote($filetype, '/')
|
|
. '__(\\d+)-([A-Za-z]+)\\.([A-Za-z0-9]+)$/';
|
|
|
|
$entries = scandir($baseDir);
|
|
if ($entries === false) {
|
|
return null;
|
|
}
|
|
|
|
$bestFile = null;
|
|
$bestNumber = -1;
|
|
$bestLetterRank = -1;
|
|
foreach ($entries as $entry) {
|
|
if (!is_string($entry) || !preg_match($pattern, $entry, $matches)) {
|
|
continue;
|
|
}
|
|
$number = (int)$matches[1];
|
|
$letterRank = self::letterSequenceToRank((string)$matches[2]);
|
|
if ($number > $bestNumber || ($number === $bestNumber && $letterRank > $bestLetterRank)) {
|
|
$bestNumber = $number;
|
|
$bestLetterRank = $letterRank;
|
|
$bestFile = $entry;
|
|
}
|
|
}
|
|
|
|
if ($bestFile === null) {
|
|
return null;
|
|
}
|
|
|
|
$path = $baseDir . '/' . $bestFile;
|
|
|
|
return is_file($path) && is_readable($path) ? $path : null;
|
|
}
|
|
|
|
private static function resolveByFilepath(string $filepath): ?string
|
|
{
|
|
$filepath = trim($filepath);
|
|
if ($filepath === '') {
|
|
return null;
|
|
}
|
|
|
|
$path = Environment::getPublicPath() . '/' . ltrim($filepath, '/');
|
|
|
|
return is_file($path) && is_readable($path) ? $path : null;
|
|
}
|
|
|
|
/** Filetype segment from the assigned filetype category (parent 4). */
|
|
private static function fileType(int $downloadUid): string
|
|
{
|
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
|
->getQueryBuilderForTable('sys_category');
|
|
$categories = $qb
|
|
->select('c.filetype')
|
|
->from('sys_category', 'c')
|
|
->join(
|
|
'c',
|
|
'sys_category_record_mm',
|
|
'mm',
|
|
'mm.uid_local = c.uid AND mm.tablenames = ' .
|
|
$qb->createNamedParameter('tx_vitec_domain_model_download', ParameterType::STRING) .
|
|
' AND mm.fieldname = ' .
|
|
$qb->createNamedParameter('categories', ParameterType::STRING)
|
|
)
|
|
->where(
|
|
$qb->expr()->eq('mm.uid_foreign', $qb->createNamedParameter($downloadUid, ParameterType::INTEGER)),
|
|
$qb->expr()->eq('c.deleted', 0),
|
|
$qb->expr()->eq('c.hidden', 0),
|
|
$qb->expr()->eq('c.parent', $qb->createNamedParameter(4, ParameterType::INTEGER))
|
|
)
|
|
->orderBy('mm.sorting', 'ASC')
|
|
->executeQuery()
|
|
->fetchAllAssociative();
|
|
|
|
foreach ($categories as $category) {
|
|
$filetype = trim((string)($category['filetype'] ?? ''));
|
|
if ($filetype !== '') {
|
|
return $filetype;
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
/** "A" = 1 ... "Z" = 26, "AA" = 27 - revision letters as a rank. */
|
|
private static function letterSequenceToRank(string $letters): int
|
|
{
|
|
$rank = 0;
|
|
foreach (str_split(strtoupper($letters)) as $char) {
|
|
$rank = $rank * 26 + (ord($char) - 64);
|
|
}
|
|
|
|
return $rank;
|
|
}
|
|
|
|
/** @return array{path: string, name: string, size: int, mimeType: string} */
|
|
private static function describe(string $path): array
|
|
{
|
|
$mimeType = function_exists('mime_content_type') ? (string)(mime_content_type($path) ?: '') : '';
|
|
|
|
return [
|
|
'path' => $path,
|
|
'name' => basename($path),
|
|
'size' => (int)(filesize($path) ?: 0),
|
|
'mimeType' => $mimeType,
|
|
];
|
|
}
|
|
}
|