Release
This commit is contained in:
48
README.md
Normal file
48
README.md
Normal 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
26
composer.json
Normal 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
14
config/services.yaml
Normal 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
3
contao/config/config.php
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
48
contao/dca/tl_form.php
Normal file
48
contao/dca/tl_form.php
Normal 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
13
contao/dca/tl_module.php
Normal 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',
|
||||||
|
];
|
||||||
45
contao/dca/tl_timed_download.php
Normal file
45
contao/dca/tl_timed_download.php
Normal 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',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
5
contao/languages/de/modules.php
Normal file
5
contao/languages/de/modules.php
Normal 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.'];
|
||||||
13
contao/languages/de/tl_form.php
Normal file
13
contao/languages/de/tl_form.php
Normal 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';
|
||||||
6
contao/languages/de/tl_module.php
Normal file
6
contao/languages/de/tl_module.php
Normal 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.'];
|
||||||
5
contao/languages/en/modules.php
Normal file
5
contao/languages/en/modules.php
Normal 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.'];
|
||||||
13
contao/languages/en/tl_form.php
Normal file
13
contao/languages/en/tl_form.php
Normal 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';
|
||||||
6
contao/languages/en/tl_module.php
Normal file
6
contao/languages/en/tl_module.php
Normal 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.'];
|
||||||
0
contao/templates/.twig-root
Normal file
0
contao/templates/.twig-root
Normal file
118
contao/templates/frontend/timed_download_link.html.twig
Normal file
118
contao/templates/frontend/timed_download_link.html.twig
Normal 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>
|
||||||
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