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

48
README.md Normal file
View File

@@ -0,0 +1,48 @@
# Limited Downloads Bundle (Contao 5.7)
Dieses Bundle erzeugt zeitlich befristete Download-Links nach einem Formularversand.
## Funktionsweise
1. Formular wird abgeschickt.
2. `processFormData`-Hook erstellt Token und Ablaufzeit.
3. Token wird in `tl_timed_download` gespeichert.
4. Token wird in der Session hinterlegt.
5. Frontend-Modul `timed_download_link` liest Token aus `?tdl=...` oder Session.
6. Modul zeigt Countdown und Download-Link.
7. Route `/download/{token}` prueft Token und Ablauf.
8. Bei gueltigem Token wird nur eine geschuetzte Datei ausgeliefert.
## Backend-Konfiguration
### Formular (`tl_form`)
- `Befristeten Download aktivieren`
- `Download-Datei` (muss in `tl_files` als geschuetzt markiert sein)
- `Gueltigkeitsdauer`
- `Zeiteinheit`
### Modul (`tl_module`)
Modultyp: `Befristeter Downloadlink`
- Ueberschrift (Standard-Modulfeld)
- Text (oberhalb des Links)
## Datenbank
Tabelle `tl_timed_download`:
- `token`
- `file_uuid`
- `expires_at`
## Installation (Privates GitHub-Repository)
1. Paket im privaten Repository bereitstellen, z.B. `eiswurm/limited-downloads-bundle`.
2. Im Projekt per Composer einbinden.
3. `vendor/bin/contao-console contao:migrate` ausfuehren.
## Hinweis
Das Bundle liefert bewusst nur Dateien aus, die in `tl_files` als `geschuetzt` markiert sind.

26
composer.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "eiswurm/limited-downloads-bundle",
"description": "Timed download links for Contao 5.7 forms.",
"type": "contao-bundle",
"license": "proprietary",
"require": {
"php": "^8.3",
"contao/core-bundle": "^5.7",
"contao/manager-plugin": "^2.0"
},
"autoload": {
"psr-4": {
"Eiswurm\\LimitedDownloadsBundle\\": "src/"
}
},
"extra": {
"contao-manager-plugin": "Eiswurm\\LimitedDownloadsBundle\\Contao\\Manager\\Plugin"
},
"prefer-stable": true,
"config": {
"allow-plugins": {
"contao-components/installer": true,
"contao/manager-plugin": true
}
}
}

14
config/services.yaml Normal file
View File

@@ -0,0 +1,14 @@
services:
_defaults:
autowire: true
autoconfigure: true
Eiswurm\LimitedDownloadsBundle\:
resource: ../src/
exclude:
- ../src/Contao/Manager/
- ../src/LimitedDownloadsBundle.php
Eiswurm\LimitedDownloadsBundle\Controller\DownloadController:
bind:
string $projectDir: '%kernel.project_dir%'

3
contao/config/config.php Normal file
View File

@@ -0,0 +1,3 @@
<?php
declare(strict_types=1);

48
contao/dca/tl_form.php Normal file
View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use Contao\CoreBundle\DataContainer\PaletteManipulator;
PaletteManipulator::create()
->addLegend('timed_download_legend', 'store_legend', PaletteManipulator::POSITION_AFTER)
->addField('timedDownloadEnabled', 'timed_download_legend', PaletteManipulator::POSITION_APPEND)
->applyToPalette('default', 'tl_form')
;
$GLOBALS['TL_DCA']['tl_form']['palettes']['__selector__'][] = 'timedDownloadEnabled';
$GLOBALS['TL_DCA']['tl_form']['subpalettes']['timedDownloadEnabled'] = 'timedDownloadFile,timedDownloadDuration,timedDownloadUnit';
$GLOBALS['TL_DCA']['tl_form']['fields']['timedDownloadEnabled'] = [
'label' => &$GLOBALS['TL_LANG']['tl_form']['timedDownloadEnabled'],
'exclude' => true,
'inputType' => 'checkbox',
'eval' => ['submitOnChange' => true, 'tl_class' => 'w50 m12'],
'sql' => ['type' => 'boolean', 'default' => false],
];
$GLOBALS['TL_DCA']['tl_form']['fields']['timedDownloadFile'] = [
'label' => &$GLOBALS['TL_LANG']['tl_form']['timedDownloadFile'],
'exclude' => true,
'inputType' => 'fileTree',
'eval' => ['fieldType' => 'radio', 'files' => true, 'mandatory' => true, 'tl_class' => 'w50'],
'sql' => ['type' => 'binary', 'length' => 16, 'notnull' => false],
];
$GLOBALS['TL_DCA']['tl_form']['fields']['timedDownloadDuration'] = [
'label' => &$GLOBALS['TL_LANG']['tl_form']['timedDownloadDuration'],
'exclude' => true,
'inputType' => 'text',
'eval' => ['mandatory' => true, 'rgxp' => 'digit', 'maxlength' => 6, 'tl_class' => 'w50'],
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 7],
];
$GLOBALS['TL_DCA']['tl_form']['fields']['timedDownloadUnit'] = [
'label' => &$GLOBALS['TL_LANG']['tl_form']['timedDownloadUnit'],
'exclude' => true,
'inputType' => 'select',
'options' => ['hours', 'days', 'weeks', 'months'],
'reference' => &$GLOBALS['TL_LANG']['tl_form']['timedDownloadUnitOptions'],
'eval' => ['mandatory' => true, 'chosen' => true, 'tl_class' => 'w50'],
'sql' => "varchar(16) NOT NULL default 'days'",
];

13
contao/dca/tl_module.php Normal file
View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
$GLOBALS['TL_DCA']['tl_module']['palettes']['timed_download_link'] = '{title_legend},name,headline,type;{timed_download_legend},timedDownloadText;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID';
$GLOBALS['TL_DCA']['tl_module']['fields']['timedDownloadText'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['timedDownloadText'],
'exclude' => true,
'inputType' => 'textarea',
'eval' => ['rte' => 'tinyMCE', 'tl_class' => 'clr'],
'sql' => 'text NULL',
];

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use Contao\DC_Table;
$GLOBALS['TL_DCA']['tl_timed_download'] = [
'config' => [
'dataContainer' => DC_Table::class,
'sql' => [
'keys' => [
'id' => 'primary',
'token' => 'unique',
'expires_at' => 'index',
'form_id' => 'index',
],
],
],
'fields' => [
'id' => [
'sql' => 'int(10) unsigned NOT NULL auto_increment',
],
'tstamp' => [
'sql' => 'int(10) unsigned NOT NULL default 0',
],
'token' => [
'sql' => "varchar(64) NOT NULL default ''",
],
'file_uuid' => [
'sql' => 'binary(16) NOT NULL',
],
'expires_at' => [
'sql' => 'int(10) unsigned NOT NULL default 0',
],
'form_id' => [
'sql' => 'int(10) unsigned NOT NULL default 0',
],
'last_download_at' => [
'sql' => 'int(10) unsigned NOT NULL default 0',
],
'download_count' => [
'sql' => 'int(10) unsigned NOT NULL default 0',
],
],
];

View File

@@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
$GLOBALS['TL_LANG']['FMD']['timed_download_link'] = ['Befristeter Downloadlink', 'Zeigt einen zeitlich begrenzten Downloadlink inklusive Countdown an.'];

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
$GLOBALS['TL_LANG']['tl_form']['timed_download_legend'] = 'Befristeter Download';
$GLOBALS['TL_LANG']['tl_form']['timedDownloadEnabled'] = ['Befristeten Download aktivieren', 'Erzeugt nach erfolgreichem Versand einen zeitlich begrenzten Downloadlink.'];
$GLOBALS['TL_LANG']['tl_form']['timedDownloadFile'] = ['Download-Datei', 'Bitte nur geschuetzte Dateiordner verwenden (Datei muss in tl_files als geschuetzt markiert sein).'];
$GLOBALS['TL_LANG']['tl_form']['timedDownloadDuration'] = ['Gueltigkeitsdauer', 'Numerischer Wert der Gueltigkeitsdauer.'];
$GLOBALS['TL_LANG']['tl_form']['timedDownloadUnit'] = ['Zeiteinheit', 'Zeiteinheit fuer die Gueltigkeitsdauer.'];
$GLOBALS['TL_LANG']['tl_form']['timedDownloadUnitOptions']['hours'] = 'Stunden';
$GLOBALS['TL_LANG']['tl_form']['timedDownloadUnitOptions']['days'] = 'Tage';
$GLOBALS['TL_LANG']['tl_form']['timedDownloadUnitOptions']['weeks'] = 'Wochen';
$GLOBALS['TL_LANG']['tl_form']['timedDownloadUnitOptions']['months'] = 'Monate';

View File

@@ -0,0 +1,6 @@
<?php
declare(strict_types=1);
$GLOBALS['TL_LANG']['tl_module']['timed_download_legend'] = 'Befristeter Download';
$GLOBALS['TL_LANG']['tl_module']['timedDownloadText'] = ['Text', 'Text oberhalb des Downloadlinks.'];

View File

@@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
$GLOBALS['TL_LANG']['FMD']['timed_download_link'] = ['Timed download link', 'Shows a time-limited download link including a countdown.'];

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
$GLOBALS['TL_LANG']['tl_form']['timed_download_legend'] = 'Timed download';
$GLOBALS['TL_LANG']['tl_form']['timedDownloadEnabled'] = ['Enable timed download', 'Creates a time-limited download link after successful form submission.'];
$GLOBALS['TL_LANG']['tl_form']['timedDownloadFile'] = ['Download file', 'Please use protected folders only (file must be marked as protected in tl_files).'];
$GLOBALS['TL_LANG']['tl_form']['timedDownloadDuration'] = ['Validity duration', 'Numeric value for the validity period.'];
$GLOBALS['TL_LANG']['tl_form']['timedDownloadUnit'] = ['Time unit', 'Time unit for the validity duration.'];
$GLOBALS['TL_LANG']['tl_form']['timedDownloadUnitOptions']['hours'] = 'Hours';
$GLOBALS['TL_LANG']['tl_form']['timedDownloadUnitOptions']['days'] = 'Days';
$GLOBALS['TL_LANG']['tl_form']['timedDownloadUnitOptions']['weeks'] = 'Weeks';
$GLOBALS['TL_LANG']['tl_form']['timedDownloadUnitOptions']['months'] = 'Months';

View File

@@ -0,0 +1,6 @@
<?php
declare(strict_types=1);
$GLOBALS['TL_LANG']['tl_module']['timed_download_legend'] = 'Timed download';
$GLOBALS['TL_LANG']['tl_module']['timedDownloadText'] = ['Text', 'Text shown above the download link.'];

View File

View File

@@ -0,0 +1,118 @@
<div class="timed-download" data-timed-download>
{% if timedDownloadText %}
<div class="timed-download__text">
{{ timedDownloadText|raw }}
</div>
{% endif %}
{% if isValid %}
<p class="timed-download__countdown" data-expires-at="{{ expiresAt }}">
Verbleibende Zeit: --:--:--
</p>
<p class="timed-download__valid-until">
Gueltig bis:
<time datetime="{{ expiresAt|date('c') }}">{{ expiresAt|date('d.m.Y H:i') }} Uhr</time>
</p>
<p class="timed-download__link">
<a href="{{ downloadUrl }}">Download starten</a>
</p>
<p class="timed-download__share-link">
<a href="{{ shareUrl|default('#') }}">Diesen Link speichern</a>
</p>
<script>
(function () {
var root = document.currentScript ? document.currentScript.closest('[data-timed-download]') : null;
if (!root) {
return;
}
var token = '{{ token|e('js') }}';
if (token) {
var currentUrl = new URL(window.location.href);
if (currentUrl.searchParams.get('tdl') !== token) {
currentUrl.searchParams.set('tdl', token);
window.history.replaceState({}, '', currentUrl.toString());
var shareLinkElement = root.querySelector('.timed-download__share-link a');
if (shareLinkElement) {
shareLinkElement.href = currentUrl.toString();
}
}
}
var fallbackShareLinkElement = root.querySelector('.timed-download__share-link a');
if (fallbackShareLinkElement && '#' === fallbackShareLinkElement.getAttribute('href')) {
fallbackShareLinkElement.href = window.location.href;
}
var element = root.querySelector('.timed-download__countdown[data-expires-at]');
if (!element) {
return;
}
var expiresAt = parseInt(element.getAttribute('data-expires-at'), 10);
if (Number.isNaN(expiresAt)) {
return;
}
function formatCountdown(secondsTotal) {
var seconds = Math.max(0, secondsTotal);
var days = Math.floor(seconds / 86400);
var hours = Math.floor((seconds % 86400) / 3600);
var minutes = Math.floor((seconds % 3600) / 60);
var secs = seconds % 60;
var hh = String(hours).padStart(2, '0');
var mm = String(minutes).padStart(2, '0');
var ss = String(secs).padStart(2, '0');
if (days > 0) {
return days + 'd ' + hh + ':' + mm + ':' + ss;
}
return hh + ':' + mm + ':' + ss;
}
function render() {
var now = Math.floor(Date.now() / 1000);
var remaining = expiresAt - now;
if (remaining <= 0) {
element.textContent = 'Der Download-Link ist abgelaufen.';
return false;
}
element.textContent = 'Verbleibende Zeit: ' + formatCountdown(remaining);
return true;
}
if (!render()) {
return;
}
var timer = window.setInterval(function () {
if (!render()) {
window.clearInterval(timer);
window.location.reload();
}
}, 1000);
})();
</script>
{% elseif isExpired %}
<p class="timed-download__expired">Der Download-Link ist abgelaufen.</p>
{% else %}
<p class="timed-download__missing">Kein gueltiger Download-Link vorhanden.</p>
{% endif %}
</div>

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()
{
}
}