From d10c160ae9e0d74a3ac9be9149abb902feeeb77f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Mummert?= Date: Fri, 6 Mar 2026 21:25:18 +0100 Subject: [PATCH] Release --- README.md | 48 +++++++ composer.json | 26 ++++ config/services.yaml | 14 +++ contao/config/config.php | 3 + contao/dca/tl_form.php | 48 +++++++ contao/dca/tl_module.php | 13 ++ contao/dca/tl_timed_download.php | 45 +++++++ contao/languages/de/modules.php | 5 + contao/languages/de/tl_form.php | 13 ++ contao/languages/de/tl_module.php | 6 + contao/languages/en/modules.php | 5 + contao/languages/en/tl_form.php | 13 ++ contao/languages/en/tl_module.php | 6 + contao/templates/.twig-root | 0 .../frontend/timed_download_link.html.twig | 118 ++++++++++++++++++ src/Contao/Manager/Plugin.php | 34 +++++ src/Controller/DownloadController.php | 59 +++++++++ .../TimedDownloadLinkModuleController.php | 88 +++++++++++++ .../LimitedDownloadsExtension.php | 19 +++ src/EventListener/FormSubmissionListener.php | 114 +++++++++++++++++ src/LimitedDownloadsBundle.php | 15 +++ src/Repository/TimedDownloadRepository.php | 113 +++++++++++++++++ src/Resources/config/routes.yaml | 3 + src/Service/ProtectedFileProvider.php | 81 ++++++++++++ src/TimedDownloadSession.php | 14 +++ 25 files changed, 903 insertions(+) create mode 100644 README.md create mode 100644 composer.json create mode 100644 config/services.yaml create mode 100644 contao/config/config.php create mode 100644 contao/dca/tl_form.php create mode 100644 contao/dca/tl_module.php create mode 100644 contao/dca/tl_timed_download.php create mode 100644 contao/languages/de/modules.php create mode 100644 contao/languages/de/tl_form.php create mode 100644 contao/languages/de/tl_module.php create mode 100644 contao/languages/en/modules.php create mode 100644 contao/languages/en/tl_form.php create mode 100644 contao/languages/en/tl_module.php create mode 100644 contao/templates/.twig-root create mode 100644 contao/templates/frontend/timed_download_link.html.twig create mode 100644 src/Contao/Manager/Plugin.php create mode 100644 src/Controller/DownloadController.php create mode 100644 src/Controller/Frontend/TimedDownloadLinkModuleController.php create mode 100644 src/DependencyInjection/LimitedDownloadsExtension.php create mode 100644 src/EventListener/FormSubmissionListener.php create mode 100644 src/LimitedDownloadsBundle.php create mode 100644 src/Repository/TimedDownloadRepository.php create mode 100644 src/Resources/config/routes.yaml create mode 100644 src/Service/ProtectedFileProvider.php create mode 100644 src/TimedDownloadSession.php diff --git a/README.md b/README.md new file mode 100644 index 0000000..02dc064 --- /dev/null +++ b/README.md @@ -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. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..9214016 --- /dev/null +++ b/composer.json @@ -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 + } + } +} diff --git a/config/services.yaml b/config/services.yaml new file mode 100644 index 0000000..7df3f43 --- /dev/null +++ b/config/services.yaml @@ -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%' diff --git a/contao/config/config.php b/contao/config/config.php new file mode 100644 index 0000000..174d7fd --- /dev/null +++ b/contao/config/config.php @@ -0,0 +1,3 @@ +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'", +]; diff --git a/contao/dca/tl_module.php b/contao/dca/tl_module.php new file mode 100644 index 0000000..17551aa --- /dev/null +++ b/contao/dca/tl_module.php @@ -0,0 +1,13 @@ + &$GLOBALS['TL_LANG']['tl_module']['timedDownloadText'], + 'exclude' => true, + 'inputType' => 'textarea', + 'eval' => ['rte' => 'tinyMCE', 'tl_class' => 'clr'], + 'sql' => 'text NULL', +]; diff --git a/contao/dca/tl_timed_download.php b/contao/dca/tl_timed_download.php new file mode 100644 index 0000000..8b434c7 --- /dev/null +++ b/contao/dca/tl_timed_download.php @@ -0,0 +1,45 @@ + [ + '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', + ], + ], +]; diff --git a/contao/languages/de/modules.php b/contao/languages/de/modules.php new file mode 100644 index 0000000..1311c31 --- /dev/null +++ b/contao/languages/de/modules.php @@ -0,0 +1,5 @@ + + {% if timedDownloadText %} +
+ {{ timedDownloadText|raw }} +
+ {% endif %} + + {% if isValid %} +

+ Verbleibende Zeit: --:--:-- +

+ +

+ Gueltig bis: + +

+ + + + + + + {% elseif isExpired %} +

Der Download-Link ist abgelaufen.

+ {% else %} +

Kein gueltiger Download-Link vorhanden.

+ {% endif %} + diff --git a/src/Contao/Manager/Plugin.php b/src/Contao/Manager/Plugin.php new file mode 100644 index 0000000..6b0996e --- /dev/null +++ b/src/Contao/Manager/Plugin.php @@ -0,0 +1,34 @@ +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') + ; + } +} diff --git a/src/Controller/DownloadController.php b/src/Controller/DownloadController.php new file mode 100644 index 0000000..7c108c1 --- /dev/null +++ b/src/Controller/DownloadController.php @@ -0,0 +1,59 @@ + '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; + } +} diff --git a/src/Controller/Frontend/TimedDownloadLinkModuleController.php b/src/Controller/Frontend/TimedDownloadLinkModuleController.php new file mode 100644 index 0000000..e1db93f --- /dev/null +++ b/src/Controller/Frontend/TimedDownloadLinkModuleController.php @@ -0,0 +1,88 @@ +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 : ''; + } +} diff --git a/src/DependencyInjection/LimitedDownloadsExtension.php b/src/DependencyInjection/LimitedDownloadsExtension.php new file mode 100644 index 0000000..578caa3 --- /dev/null +++ b/src/DependencyInjection/LimitedDownloadsExtension.php @@ -0,0 +1,19 @@ +load('services.yaml'); + } +} diff --git a/src/EventListener/FormSubmissionListener.php b/src/EventListener/FormSubmissionListener.php new file mode 100644 index 0000000..c5864ab --- /dev/null +++ b/src/EventListener/FormSubmissionListener.php @@ -0,0 +1,114 @@ +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); + } +} diff --git a/src/LimitedDownloadsBundle.php b/src/LimitedDownloadsBundle.php new file mode 100644 index 0000000..37f66c2 --- /dev/null +++ b/src/LimitedDownloadsBundle.php @@ -0,0 +1,15 @@ +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|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|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))); + } +} diff --git a/src/Resources/config/routes.yaml b/src/Resources/config/routes.yaml new file mode 100644 index 0000000..b3f5faa --- /dev/null +++ b/src/Resources/config/routes.yaml @@ -0,0 +1,3 @@ +eiswurm_limited_download_controllers: + resource: ../../Controller/ + type: attribute diff --git a/src/Service/ProtectedFileProvider.php b/src/Service/ProtectedFileProvider.php new file mode 100644 index 0000000..1602737 --- /dev/null +++ b/src/Service/ProtectedFileProvider.php @@ -0,0 +1,81 @@ +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; + } + } +} diff --git a/src/TimedDownloadSession.php b/src/TimedDownloadSession.php new file mode 100644 index 0000000..f809452 --- /dev/null +++ b/src/TimedDownloadSession.php @@ -0,0 +1,14 @@ +