This commit is contained in:
Jürgen Mummert
2026-03-06 21:25:18 +01:00
commit d10c160ae9
25 changed files with 903 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Eiswurm\LimitedDownloadsBundle\Contao\Manager;
use Contao\CoreBundle\ContaoCoreBundle;
use Contao\ManagerPlugin\Bundle\BundlePluginInterface;
use Contao\ManagerPlugin\Bundle\Config\BundleConfig;
use Contao\ManagerPlugin\Bundle\Parser\ParserInterface;
use Contao\ManagerPlugin\Routing\RoutingPluginInterface;
use Eiswurm\LimitedDownloadsBundle\LimitedDownloadsBundle;
use Symfony\Component\Config\Loader\LoaderResolverInterface;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Routing\RouteCollection;
class Plugin implements BundlePluginInterface, RoutingPluginInterface
{
public function getBundles(ParserInterface $parser): iterable
{
return [
BundleConfig::create(LimitedDownloadsBundle::class)
->setLoadAfter([ContaoCoreBundle::class]),
];
}
public function getRouteCollection(LoaderResolverInterface $resolver, KernelInterface $kernel): ?RouteCollection
{
return $resolver
->resolve(__DIR__.'/../../Resources/config/routes.yaml')
?->load(__DIR__.'/../../Resources/config/routes.yaml')
;
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Eiswurm\LimitedDownloadsBundle\Controller;
use Eiswurm\LimitedDownloadsBundle\Repository\TimedDownloadRepository;
use Eiswurm\LimitedDownloadsBundle\Service\ProtectedFileProvider;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
class DownloadController
{
public function __construct(
private readonly TimedDownloadRepository $timedDownloadRepository,
private readonly ProtectedFileProvider $protectedFileProvider,
private readonly string $projectDir,
) {
}
#[Route('/download/{token}', name: 'eiswurm_limited_download', defaults: ['_scope' => 'frontend'], requirements: ['token' => '[A-Fa-f0-9]{64}'], methods: ['GET'])]
public function __invoke(string $token): BinaryFileResponse
{
$now = time();
$entry = $this->timedDownloadRepository->findValidByToken($token, $now);
if (null === $entry) {
throw new NotFoundHttpException('Download token not found or expired.');
}
$fileUuidBinary = (string) ($entry['file_uuid'] ?? '');
$file = $this->protectedFileProvider->findProtectedPath($fileUuidBinary);
if (null === $file) {
throw new NotFoundHttpException('Protected file not found.');
}
$absolutePath = rtrim($this->projectDir, '/').'/'.ltrim((string) $file['path'], '/');
if (!is_file($absolutePath) || !is_readable($absolutePath)) {
throw new NotFoundHttpException('File does not exist on filesystem.');
}
$response = new BinaryFileResponse($absolutePath);
$response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
(string) $file['name'],
);
$this->timedDownloadRepository->recordDownload($token, $now);
$response->headers->set('Cache-Control', 'private, no-store, no-cache, must-revalidate');
$response->headers->set('Pragma', 'no-cache');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-Robots-Tag', 'noindex, nofollow, noarchive');
return $response;
}
}

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace Eiswurm\LimitedDownloadsBundle\Controller\Frontend;
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
use Contao\CoreBundle\Twig\FragmentTemplate;
use Contao\ModuleModel;
use Eiswurm\LimitedDownloadsBundle\Repository\TimedDownloadRepository;
use Eiswurm\LimitedDownloadsBundle\TimedDownloadSession;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
#[AsFrontendModule(type: 'timed_download_link', category: 'miscellaneous', template: 'frontend/timed_download_link')]
class TimedDownloadLinkModuleController extends AbstractFrontendModuleController
{
public function __construct(
private readonly TimedDownloadRepository $timedDownloadRepository,
private readonly RequestStack $requestStack,
private readonly UrlGeneratorInterface $urlGenerator,
) {
}
protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response
{
$token = $this->resolveToken($request);
if ('' === $token) {
$template->set('timedDownloadText', (string) ($model->timedDownloadText ?? ''));
$template->set('isValid', false);
$template->set('isExpired', false);
return $template->getResponse();
}
$entry = $this->timedDownloadRepository->findByToken($token);
if (null === $entry) {
$template->set('timedDownloadText', (string) ($model->timedDownloadText ?? ''));
$template->set('isValid', false);
$template->set('isExpired', false);
return $template->getResponse();
}
$expiresAt = (int) ($entry['expires_at'] ?? 0);
$isExpired = $expiresAt < time();
$template->set('timedDownloadText', (string) ($model->timedDownloadText ?? ''));
$template->set('isValid', !$isExpired);
$template->set('isExpired', $isExpired);
$template->set('token', $token);
$template->set('expiresAt', $expiresAt);
$template->set('shareUrl', $request->getSchemeAndHttpHost().$request->getPathInfo().'?tdl='.$token);
$template->set('downloadUrl', $this->urlGenerator->generate('eiswurm_limited_download', ['token' => $token]));
return $template->getResponse();
}
private function resolveToken(Request $request): string
{
$queryToken = trim((string) $request->query->get('tdl', ''));
if ('' !== $queryToken && $this->timedDownloadRepository->isTokenFormatValid($queryToken)) {
return $queryToken;
}
$session = $this->requestStack->getCurrentRequest()?->getSession();
if (null === $session) {
return '';
}
$entry = $session->get(TimedDownloadSession::KEY);
if (!\is_array($entry)) {
return '';
}
$sessionToken = trim((string) ($entry['token'] ?? ''));
return $this->timedDownloadRepository->isTokenFormatValid($sessionToken) ? $sessionToken : '';
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Eiswurm\LimitedDownloadsBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
class LimitedDownloadsExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container): void
{
$loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../../config'));
$loader->load('services.yaml');
}
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace Eiswurm\LimitedDownloadsBundle\EventListener;
use Contao\CoreBundle\DependencyInjection\Attribute\AsHook;
use Contao\Form;
use Contao\StringUtil;
use Eiswurm\LimitedDownloadsBundle\Repository\TimedDownloadRepository;
use Eiswurm\LimitedDownloadsBundle\Service\ProtectedFileProvider;
use Eiswurm\LimitedDownloadsBundle\TimedDownloadSession;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
#[AsHook('processFormData', method: 'onProcessFormData')]
class FormSubmissionListener
{
private const MAX_VALIDITY_SECONDS = 31536000;
public function __construct(
private readonly TimedDownloadRepository $timedDownloadRepository,
private readonly ProtectedFileProvider $protectedFileProvider,
private readonly RequestStack $requestStack,
private readonly ?LoggerInterface $logger = null,
) {
}
public function onProcessFormData(array $submittedData, array $formData, array $files, array $labels, Form $form): void
{
if (!$this->isEnabled($formData)) {
return;
}
$fileUuidBinary = $this->normalizeUuidToBinary((string) ($formData['timedDownloadFile'] ?? ''));
$duration = max(0, (int) ($formData['timedDownloadDuration'] ?? 0));
$unit = (string) ($formData['timedDownloadUnit'] ?? 'days');
if (null === $fileUuidBinary || $duration <= 0) {
return;
}
if (!$this->protectedFileProvider->isProtected($fileUuidBinary)) {
$this->logger?->warning('Timed download file is not marked as protected in tl_files.', [
'formId' => (int) ($formData['id'] ?? 0),
]);
return;
}
$now = time();
$requestedValidity = $duration * $this->secondsPerUnit($unit);
$validitySeconds = min(self::MAX_VALIDITY_SECONDS, max(0, $requestedValidity));
if ($validitySeconds !== $requestedValidity) {
$this->logger?->info('Timed download validity was capped to max configured value.', [
'formId' => (int) ($formData['id'] ?? 0),
'requestedSeconds' => $requestedValidity,
'effectiveSeconds' => $validitySeconds,
]);
}
if ($validitySeconds <= 0) {
return;
}
$expiresAt = $now + $validitySeconds;
$token = $this->timedDownloadRepository->create($fileUuidBinary, $expiresAt, (int) ($formData['id'] ?? 0));
$this->requestStack->getCurrentRequest()?->getSession()?->set(TimedDownloadSession::KEY, [
'token' => $token,
'createdAt' => $now,
'expiresAt' => $expiresAt,
]);
}
private function isEnabled(array $formData): bool
{
$value = $formData['timedDownloadEnabled'] ?? '';
return '1' === (string) $value || true === $value;
}
private function secondsPerUnit(string $unit): int
{
return match ($unit) {
'hours' => 3600,
'weeks' => 604800,
'months' => 2628000,
default => 86400,
};
}
private function normalizeUuidToBinary(string $value): ?string
{
$trimmedValue = trim($value);
if ('' === $trimmedValue) {
return null;
}
if (16 === strlen($trimmedValue)) {
return $trimmedValue;
}
$normalized = str_replace(['{', '}'], '', $trimmedValue);
if (!preg_match('/^[a-f0-9\-]{36}$/i', $normalized)) {
return null;
}
return StringUtil::uuidToBin($normalized);
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Eiswurm\LimitedDownloadsBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class LimitedDownloadsBundle extends Bundle
{
public function getPath(): string
{
return dirname(__DIR__);
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Eiswurm\LimitedDownloadsBundle\Repository;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\DBAL\ParameterType;
class TimedDownloadRepository
{
private const TOKEN_PATTERN = '/^[a-f0-9]{64}$/';
public function __construct(
private readonly Connection $connection,
) {
}
public function create(string $fileUuidBinary, int $expiresAt, int $formId = 0): string
{
$now = time();
for ($attempt = 0; $attempt < 3; ++$attempt) {
$token = bin2hex(random_bytes(32));
try {
$this->connection->insert('tl_timed_download', [
'tstamp' => $now,
'token' => $token,
'file_uuid' => $fileUuidBinary,
'expires_at' => $expiresAt,
'form_id' => $formId,
], [
'tstamp' => ParameterType::INTEGER,
'token' => ParameterType::STRING,
'file_uuid' => ParameterType::BINARY,
'expires_at' => ParameterType::INTEGER,
'form_id' => ParameterType::INTEGER,
]);
return $token;
} catch (UniqueConstraintViolationException) {
continue;
}
}
throw new \RuntimeException('Could not create a unique download token.');
}
/** @return array<string, mixed>|null */
public function findByToken(string $token): ?array
{
if (!$this->isTokenFormatValid($token)) {
return null;
}
$row = $this->connection->createQueryBuilder()
->select('token', 'file_uuid', 'expires_at', 'form_id')
->from('tl_timed_download')
->where('token = :token')
->setParameter('token', $token)
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
return false === $row ? null : $row;
}
/** @return array<string, mixed>|null */
public function findValidByToken(string $token, int $currentTime): ?array
{
if (!$this->isTokenFormatValid($token)) {
return null;
}
$row = $this->connection->createQueryBuilder()
->select('token', 'file_uuid', 'expires_at', 'form_id')
->from('tl_timed_download')
->where('token = :token')
->andWhere('expires_at >= :now')
->setParameter('token', $token)
->setParameter('now', $currentTime, ParameterType::INTEGER)
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
return false === $row ? null : $row;
}
public function recordDownload(string $token, int $timestamp): void
{
if (!$this->isTokenFormatValid($token)) {
return;
}
$this->connection->createQueryBuilder()
->update('tl_timed_download')
->set('tstamp', ':tstamp')
->set('last_download_at', ':lastDownloadAt')
->set('download_count', 'download_count + 1')
->where('token = :token')
->setParameter('tstamp', $timestamp, ParameterType::INTEGER)
->setParameter('lastDownloadAt', $timestamp, ParameterType::INTEGER)
->setParameter('token', $token)
->executeStatement();
}
public function isTokenFormatValid(string $token): bool
{
return 1 === preg_match(self::TOKEN_PATTERN, strtolower(trim($token)));
}
}

View File

@@ -0,0 +1,3 @@
eiswurm_limited_download_controllers:
resource: ../../Controller/
type: attribute

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Eiswurm\LimitedDownloadsBundle\Service;
use Contao\File;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ParameterType;
class ProtectedFileProvider
{
public function __construct(
private readonly Connection $connection,
) {
}
public function isProtected(string $uuidBinary): bool
{
$path = $this->connection->createQueryBuilder()
->select('path')
->from('tl_files')
->where('uuid = :uuid')
->andWhere('type = :type')
->setParameter('uuid', $uuidBinary, ParameterType::BINARY)
->setParameter('type', 'file')
->setMaxResults(1)
->executeQuery()
->fetchOne();
if (false === $path || '' === (string) $path) {
return false;
}
return $this->isPathProtected((string) $path);
}
/** @return array{path: string, name: string}|null */
public function findProtectedPath(string $uuidBinary): ?array
{
$row = $this->connection->createQueryBuilder()
->select('path', 'name')
->from('tl_files')
->where('uuid = :uuid')
->andWhere('type = :type')
->setParameter('uuid', $uuidBinary, ParameterType::BINARY)
->setParameter('type', 'file')
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
if (false === $row) {
return null;
}
$path = (string) ($row['path'] ?? '');
$name = (string) ($row['name'] ?? '');
if ('' === $path) {
return null;
}
if (!$this->isPathProtected($path)) {
return null;
}
return [
'path' => $path,
'name' => '' !== $name ? $name : basename($path),
];
}
private function isPathProtected(string $path): bool
{
try {
return !(new File($path))->isUnprotected();
} catch (\Throwable) {
return false;
}
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Eiswurm\LimitedDownloadsBundle;
final class TimedDownloadSession
{
public const KEY = 'eiswurm_limited_download';
private function __construct()
{
}
}