Release
This commit is contained in:
34
src/Contao/Manager/Plugin.php
Normal file
34
src/Contao/Manager/Plugin.php
Normal 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')
|
||||
;
|
||||
}
|
||||
}
|
||||
59
src/Controller/DownloadController.php
Normal file
59
src/Controller/DownloadController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 : '';
|
||||
}
|
||||
}
|
||||
19
src/DependencyInjection/LimitedDownloadsExtension.php
Normal file
19
src/DependencyInjection/LimitedDownloadsExtension.php
Normal 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');
|
||||
}
|
||||
}
|
||||
114
src/EventListener/FormSubmissionListener.php
Normal file
114
src/EventListener/FormSubmissionListener.php
Normal 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);
|
||||
}
|
||||
}
|
||||
15
src/LimitedDownloadsBundle.php
Normal file
15
src/LimitedDownloadsBundle.php
Normal 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__);
|
||||
}
|
||||
}
|
||||
113
src/Repository/TimedDownloadRepository.php
Normal file
113
src/Repository/TimedDownloadRepository.php
Normal 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)));
|
||||
}
|
||||
}
|
||||
3
src/Resources/config/routes.yaml
Normal file
3
src/Resources/config/routes.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
eiswurm_limited_download_controllers:
|
||||
resource: ../../Controller/
|
||||
type: attribute
|
||||
81
src/Service/ProtectedFileProvider.php
Normal file
81
src/Service/ProtectedFileProvider.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/TimedDownloadSession.php
Normal file
14
src/TimedDownloadSession.php
Normal 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()
|
||||
{
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user