diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4f92570 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +# For more information about the properties used in this file, +# please see the EditorConfig documentation: +# http://editorconfig.org + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[{*.yml,package.json,*.js,*.scss}] +indent_size = 2 +indent_style = space diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..db7f7e0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +/tests export-ignore +/.gitattributes export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bdd032d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +.phpunit.result.cache diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..854139a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1 @@ +# Contributing diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9e29ad1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,12 @@ +Copyright (c) 2023 Martin Heise +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + + * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d31a826 --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# SilverStripe Download Codes + +An extension for SilverStripe for generating download codes to give frontend users special access to protected files. A typical use case would be the download of digital music albums via codes provided with the LP version or sold separately. + +## Requirements + +Requires Silverstripe 5.x, still works with Silverstripe 4 – see respective branches `4` and `5` + + +## Installation and setup + +Install with composer: + + composer require mhe/silverstripe-download-codes ^1.0 + +Perform `dev/build` task + +Add a page of type “Download Page” to your site. This will provide a form where users can redeem their code. You might want to deactivate the “Show in menus” setting and only communicate the URL together with the download codes. + + +### Recommended extensions + +These extensions will improve the experience, but are optional: + +- [bummzack/sortablefile](https://packagist.org/packages/bummzack/sortablefile): Support for sortable UploadFields, to sort Download Package files +- [colymba/gridfield-bulk-editing-tools](https://packagist.org/packages/colymba/gridfield-bulk-editing-tools): Support for bulk actions in Admin area +- PHP with enabled [ext/zip](https://www.php.net/manual/en/book.zip.php): Providing download packages as zip + + +## Backend usage + +The administration of download codes is done in the Admin area “Download Codes”. + +Basically you will create a Download Package with the desired files, create or generate Download Codes connected to this package, and provide these codes to users / customers in some way. + + +### Download Packages + +A download package is a collection of files and common metadata that is provided as one code protected download. + +The downloadable files should be protected against public access either directly or by inherited access from their parent folder. The admin grid view will give a warning if unprotected files are part of a package. + +*Fields:* + +- _Title_: Name of the package – can be displayed to users to describe the whole download – e.g. name of a music album +- _Preview Image_: Image representing thre whole download – e.g. a LP cover +- _Files_: The actual downloadable content files +- _Provide ZIP download_: Allow users to download all package files as ZIP + + +### Download Codes + +A download code represents the download permission for a specific package. + +A download code can either be limited (meant to be used be exactly one user) or un-limited (meant to be sent to multiple persons, e.g. for some marketing event) + +Standard CSV export is enabled, which often will be helpful to distribute generated codes. + +*Fields:* + +- _Code_: The actual code you will send to a user / customer +- _Expiration Date_: (optional) The code can’t be redeemed after this date +- _Active_: If false, the code can’t be redeemed. Is set automatically to false when the usage_limit was reached (only for limited codes) +- _Limited_: Limited usage by one user +- _Distributed_: Mark the code as distributed to users / customers, to keep track of required codes +- _Package_: The download package handled by this code +- _Usage count_: (readonly) count of successful redemptions +- _Note_: Arbitrary note for internal use + +#### Tab Redemptions + +Read-only list of successful redemptions with dates of creation and their expiration + +#### Action “Generate Codes” + +With this action a bigger number of codes can be create/prepared in one step. The desired number will be created, filled with common settings, the Code field will be set each to unique value according to configured length and characters. (see „Configuration options“ below) + +*Dialog Fields:* + +- _Quantitiy_: number of download codes to generate +- _Expiration Date_: (optional) Set for all generated codes +- _Limited_: (optional) Set for all generated codes +- _Package_: (required) Set for all generated codes +- _Note_: Arbitrary note for internal use + +#### Batch actions + +Perform actions on multiple selection Download codes: (requires [colymba/gridfield-bulk-editing-tools](https://packagist.org/packages/colymba/gridfield-bulk-editing-tools)) + +- Delete +- Mark as distributed +- Remove distributed mark + + +## Configuration + +The following templates are included providing the basic funcionality. You can adjust them via a theme or project templates to adjust the layout to your needs. + +### Templates + +- `Mhe/DownloadCodes/Model/Layout/DLPage_redeem.ss`: Layout of the DownloadPage containing the main code redemption form +- `Mhe/DownloadCodes/Model/Layout/DLPage.ss`: Displayed after successful entering of a valid code, containing the actual links to downloadable files + +### Configuration options via Silverstripe YAML configuration + +#### Mhe\DownloadCodes\Model\DLCode + +- _autogenerate_chars_: string containing valid characters for auto generated codes (default: "ABCDEFGHIJAKLMNOPQRSTUVWXYZ0123456789") +- _autogenerate_length_: length of auto generated codes (default: 8) +- _strip_whitespace_: if true strips whitespace from user input for codes (default: false) +- _case_sensitive_: user’s code input needs to match the case of the valid code (default: true), otherwise matches case-insensitive +- _usage_limit_: number of attempts a regular code can be redeemed (default: 5). The actual file download after redemption (in case of download problems etc) is not limited by this. + +#### Mhe\DownloadCodes\Model\DLRedemption + +- _validity_days_: number of days a code redemption with the unique URL part will be valid and can be used for download + + +## Available Backend Permissions + +- _Access to 'Download Codes' section_: view download codes and packages +- _Edit download packages_: create, edit, delete download packages +- _Edit download codes_: create, edit, delete download codes diff --git a/_config.php b/_config.php new file mode 100644 index 0000000..b3d9bbc --- /dev/null +++ b/_config.php @@ -0,0 +1 @@ + + + CodeSniffer ruleset for SilverStripe coding conventions. + + + + + + + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..5dd4d8e --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,20 @@ + + + + src/ + + + tests/ + + + + + tests + + + + + sanitychecks + + + diff --git a/src/Controller/DLCodeAdmin.php b/src/Controller/DLCodeAdmin.php new file mode 100644 index 0000000..9880e7e --- /dev/null +++ b/src/Controller/DLCodeAdmin.php @@ -0,0 +1,129 @@ + ['title' => "Download Packages", 'dataClass' => DLPackage::class], + 'codes' => ['title' => "Download Codes", 'dataClass' => DLCode::class] + ]; + + private static $model_importers = [ + // duplicate entry because of bug in ModelAdmin, see https://github.com/silverstripe/silverstripe-admin/issues/1364 + 'codes' => CsvBulkLoader::class, + DLCode::class => CsvBulkLoader::class + ]; + + private static $required_permission_codes = 'CMS_ACCESS_DLCodeAdmin'; + + private static $menu_icon_class = 'font-icon-down-circled'; + + private static $allowed_actions = [ + 'GenerateForm' + ]; + + protected function init() + { + parent::init(); + Requirements::css('mhe/silverstripe-download-codes:client/dist/css/admin.css'); + } + + + /** + * Enhance Model tabs + * @return \SilverStripe\ORM\ArrayList + */ + protected function getManagedModelTabs() + { + $tabs = parent::getManagedModelTabs(); + foreach ($tabs as $tab) { + // localize tab name + $tab->Title = _t($tab->ClassName . '.ADMIN_TABNAME', $tab->Title); + } + return $tabs; + } + + + protected function getGridFieldConfig(): GridFieldConfig + { + $config = parent::getGridFieldConfig(); + if ($this->modelTab == 'packages') { + $config->addComponent( + RowValidation::create(), GridFieldEditButton::class + ); + } + if ($this->modelTab == 'codes') { + if (singleton(DLCode::class)->canCreate()) { + $config->addComponent( + $button = GenerateCodesButton::create('buttons-before-left') + ->setImportForm($this->GenerateForm()) + ->setModalTitle(_t(__CLASS__ . '.GENERATE_CODES', 'Generate Codes')) + ); + + // enable bulk deletion if available + if (class_exists('\Colymba\BulkManager\BulkManager')) { + $config->addComponent(BulkManager::create([], false) + ->addBulkAction(DeleteHandler::class) + ->addBulkAction(MarkDistributedHandler::class) + ->addBulkAction(UnmarkDistributedHandler::class) + ); + } + + } } + return $config; + } + + + /** + * Form for Generation of codes + * @return GenerateCodesForm|false + */ + public function GenerateForm() + { + $obj = singleton(DLCode::class); + + $form = new GenerateCodesForm( + $this, + 'GenerateForm', + $obj, + $this->Link($this->sanitiseClassName($this->modelTab)), + ); + $this->extend('updateGenerateForm', $form); + return $form; + } + + public function generate($data, $form, $request) + { + // ToDo: regular form validation necessary? + if ($data['Quantity'] > 0 && $data['PackageID'] > 0) { + for ($i = 0; $i < $data['Quantity']; $i++) { + $code = DLCode::autoGenerate( + [ + 'Limited' => $data['Limited'], + 'Expires' => $data['Expires'], + 'PackageID' => $data['PackageID'], + 'Note' => $data['Note'] + ] + ); + } + } + return $this->redirectBack(); + } +} diff --git a/src/Forms/DLRequestForm.php b/src/Forms/DLRequestForm.php new file mode 100644 index 0000000..4d459b1 --- /dev/null +++ b/src/Forms/DLRequestForm.php @@ -0,0 +1,42 @@ +getFormFields(); + $actions = new FieldList( + FormAction::create('submitcode', _t(__CLASS__ . '.ACTION_submitcode', 'Submit')) + ); + $validator = new RequiredFields('Code');; + parent::__construct($controller, $name, $fields, $actions, $validator); + } + + /** + * Get the FieldList for the form, possibly using extensions + * + * @return FieldList + */ + protected function getFormFields() + { + $fields = FieldList::create( + TextField::create('Code', _t(__CLASS__ . '.FIELD_Code', 'Code')) + ); + $this->extend('updateFormFields', $fields); + return $fields; + } +} diff --git a/src/Forms/GenerateCodesForm.php b/src/Forms/GenerateCodesForm.php new file mode 100644 index 0000000..4e42f0c --- /dev/null +++ b/src/Forms/GenerateCodesForm.php @@ -0,0 +1,50 @@ +setValue(10), + $obj->dbObject('Expires')->scaffoldFormField(null, []), + $obj->dbObject('Limited')->scaffoldFormField(null, [])->setValue(true), + $obj->dbObject('PackageID')->scaffoldFormField(null, []), + $obj->dbObject('Note')->scaffoldFormField(null, []) + ); + + $actions = new FieldList( + FormAction::create( + 'generate', + _t(__CLASS__ . '.GENERATE_CODES', 'Generate Codes'), + 'Generate Codes' + )->addExtraClass('btn btn-outline-secondary font-icon-upload') + ); + + $validator = new RequiredFields(['Quantity', 'PackageID']); + + parent::__construct($controller, $name, $fields, $actions, $validator); + + $this->setFormAction( + Controller::join_links($link, $name) + ); + } + +} diff --git a/src/Forms/GridField/GenerateCodesButton.php b/src/Forms/GridField/GenerateCodesButton.php new file mode 100644 index 0000000..ebf1649 --- /dev/null +++ b/src/Forms/GridField/GenerateCodesButton.php @@ -0,0 +1,71 @@ +ID() . '_GenerateModal'; + + // Check for form message prior to rendering form (which clears session messages) + $form = $this->getImportForm(); + $hasMessage = $form && $form->getMessage(); + + // Render modal + $template = SSViewer::get_templates_by_class(static::class, '_Modal'); + $viewer = new ArrayData([ + 'ImportModalTitle' => $this->getModalTitle(), + 'ImportModalID' => $modalID, + 'ImportIframe' => $this->getImportIframe(), + 'ImportForm' => $this->getImportForm(), + ]); + $modal = $viewer->renderWith($template)->forTemplate(); + + // Build action button + $button = new GridField_FormAction( + $gridField, + 'import', + _t('Mhe\\DownloadCodes\\Controller\\DLCodeAdmin.GENERATE_CODES', 'Generate Codes'), + 'import', + [] + ); + $button + ->addExtraClass('btn btn-secondary font-icon-sync btn--icon-large action_import') // action_import: important for using standard modal funcionality + ->setForm($gridField->getForm()) + ->setAttribute('data-toggle', 'modal') + ->setAttribute('aria-controls', $modalID) + ->setAttribute('data-target', "#{$modalID}") + ->setAttribute('data-modal', $modal); + + // If form has a message, trigger it to automatically open + if ($hasMessage) { + $button->setAttribute('data-state', 'open'); + } + + return [ + $this->targetFragment => $button->Field() + ]; + } + + + + +} diff --git a/src/Forms/GridField/RowValidation.php b/src/Forms/GridField/RowValidation.php new file mode 100644 index 0000000..0830330 --- /dev/null +++ b/src/Forms/GridField/RowValidation.php @@ -0,0 +1,53 @@ +hasMethod('gridFieldValidation')) { + $valid = $record->gridFieldValidation(); + if ($valid) { + $attributes['class'] = 'font-icon-check-mark-circle'; + } else { + $attributes['class'] = 'font-icon-cancel-circled'; + if ($record->hasMethod('gridFieldValidationMessage')) { + $content = $record->gridFieldValidationMessage(); + } else { + $content = _t(__CLASS__ . '.DefaultMessage', 'Warning'); + } + } + } + return HTML::createTag('span', $attributes, $content); + } + + public function getColumnAttributes($gridField, $record, $columnName) + { + return ['class' => 'grid-field__row-validation']; + } + + public function getColumnMetadata($gridField, $columnName) + { + return ['title' => _t(__CLASS__ . '.ColumnTitle', 'Validation')]; + } +} diff --git a/src/Mhe/DownloadCodes/Controller/MarkDistributedHandler.php b/src/Mhe/DownloadCodes/Controller/MarkDistributedHandler.php new file mode 100644 index 0000000..35d76f0 --- /dev/null +++ b/src/Mhe/DownloadCodes/Controller/MarkDistributedHandler.php @@ -0,0 +1,59 @@ + 'mark', + ); + + protected $label = 'Mark as distributed'; + + public function getI18nLabel() + { + return _t(self::class . '.ACTION_LABEL', $this->getLabel()); + } + + /** + * action handler: mark given DLCode records as distributed + * @param HTTPRequest $request + * @return HTTPBulkToolsResponse + */ + public function mark(HTTPRequest $request) + { + $response = new HTTPBulkToolsResponse(true, $this->gridField); + + try { + foreach ($this->getRecords() as $record) { + if ($record instanceof DLCode) { + $response->addSuccessRecord($record); + $record->Distributed = true; + $record->write(); + } + } + $doneCount = count($response->getSuccessRecords() ?? []); + $message = sprintf( + 'Marked %1$d records.', + $doneCount + ); + $response->setMessage($message); + } catch (Exception $ex) { + $response->setStatusCode(500); + $response->setMessage($ex->getMessage()); + } + return $response; + } + + +} diff --git a/src/Mhe/DownloadCodes/Controller/UnmarkDistributedHandler.php b/src/Mhe/DownloadCodes/Controller/UnmarkDistributedHandler.php new file mode 100644 index 0000000..461678b --- /dev/null +++ b/src/Mhe/DownloadCodes/Controller/UnmarkDistributedHandler.php @@ -0,0 +1,59 @@ + 'unmark', + ); + + protected $label = 'Unmark as distributed'; + + public function getI18nLabel() + { + return _t(self::class . '.ACTION_LABEL', $this->getLabel()); + } + + /** + * action handler: mark given DLCode records as distributed + * @param HTTPRequest $request + * @return HTTPBulkToolsResponse + */ + public function unmark(HTTPRequest $request) + { + $response = new HTTPBulkToolsResponse(true, $this->gridField); + + try { + foreach ($this->getRecords() as $record) { + if ($record instanceof DLCode) { + $response->addSuccessRecord($record); + $record->Distributed = false; + $record->write(); + } + } + $doneCount = count($response->getSuccessRecords() ?? []); + $message = sprintf( + 'Unmarked %1$d records.', + $doneCount + ); + $response->setMessage($message); + } catch (Exception $ex) { + $response->setStatusCode(500); + $response->setMessage($ex->getMessage()); + } + return $response; + } + + +} diff --git a/src/Model/DLCode.php b/src/Model/DLCode.php new file mode 100644 index 0000000..a7e4569 --- /dev/null +++ b/src/Model/DLCode.php @@ -0,0 +1,330 @@ + 'Varchar(255)', + 'Expires' => 'Datetime', + 'Active' => 'Boolean', + 'Limited' => 'Boolean', + 'UsageCount' => 'Int', + 'Distributed' => 'Boolean', + 'Note' => 'Varchar(255)' + ]; + + private static $defaults = [ + 'Active' => true, + 'Limited' => true, + 'UsageCount' => 0, + 'Distributed' => false + ]; + + private static $indexes = [ + 'Code' => [ + 'type' => 'unique', + 'columns' => ['Code'], + ], + ]; + + private static $has_one = [ + 'Package' => DLPackage::class + ]; + + private static $has_many = [ + 'Redemptions' => DLRedemption::class, + ]; + + private static $summary_fields = [ + 'Code', + 'Package.Title', + 'Limited', + 'Active', + 'Distributed', + 'Note' + ]; + + + private static $searchable_fields = [ + 'Package.Title', + 'Limited', + 'Active', + 'Distributed', + 'Note' + ]; + + /** + * get CMS fields – using default scaffolding and keeping possibility for extension + * @return FieldList + */ + public function getCMSFields() + { + $fields = $this->scaffoldFormFields([ + 'includeRelations' => ($this->ID > 0), + 'tabbed' => true, + 'ajaxSafe' => true + ]); + $code = $fields->fieldByName('Root.Main.Code'); + $usagecount = $fields->fieldByName('Root.Main.UsageCount')->setReadonly(true); + $fields->addFieldToTab('Root.Main', $usagecount); + // simple readonly grid field for Redemptions + $redemptions = $fields->fieldByName('Root.Redemptions.Redemptions'); + if ($redemptions) $redemptions->setConfig(GridFieldConfig_Base::create()); + + $this->extend('updateCMSFields', $fields); + return $fields; + } + + /** + * enhance field labels with custom values for summary/search fields + * @param $includerelations + * @return array + */ + public function fieldLabels($includerelations = true) + { + $labels = parent::fieldLabels($includerelations); + $labels['Package.Title'] = _t( + __CLASS__ . '.has_one_Package', + 'Package' + ); + return $labels; + } + + + /** + * create a new unique DLCode + * @param array $args optional default properties + * @param boolean $doWrite If true (default) immediately save the object + * @return DLCode + * @throws \SilverStripe\ORM\ValidationException + */ + public static function autoGenerate($args = [], $doWrite = true) + { + $code = static::create($args); + $tries = 0; + do { + // ToDo: what is an appropriate count of tries? + // ToDo: handle error in a user friendly way + if ($tries > 10) { + throw new \Exception('couldn’t get unique code – check options / configuration'); + } + $tries++; + $code->Code = self::randomCode(); + } while ( + // assure unique codes – force case-insensitive search (default for MySQL anyway) + self::get()->filter('Code:nocase', $code->Code)->exists() + ); + if ($doWrite) { + $code->write(); + } + return $code; + } + + /** + * Generate a random code + * @param int $length number of characters for the code + * @return string + */ + protected static function randomCode() + { + $chars = static::config()->get('autogenerate_chars'); + $length = static::config()->get('autogenerate_length'); + $c = ''; + for ($i = 0; $i < $length; $i++) { + $c .= substr($chars, random_int(0, strlen($chars) - 1), 1); + } + return $c; + } + + /** + * custom validaton for unique Codes + * @return \SilverStripe\ORM\ValidationResult + */ + public function validate() + { + $result = parent::validate(); + // assure unique codes – force case-insensitive search (default for MySQL anyway) + $existing = self::get()->filter('Code:nocase', $this->Code); + if ($this->ID) { + $existing = $existing->exclude('ID', $this->ID); + } + if ($existing->exists()) { + $result->addError(_t(__CLASS__ . '.ERROR_Unique_Code', 'Code is already in use')); + } + return $result; + } + + /** + * Get one redeeamable DLCode for given code string + * @param string $code Code + * @return DataObject|null + */ + public static function get_redeemable_code($code) + { + $modifier = static::config()->get('case_sensitive') ? ':case' : ':nocase'; + if (static::config()->get('strip_whitespace')) { + $code = trim($code); + } + $obj = self::get()->filter([ + "Code{$modifier}" => $code, + 'Active' => 1])->first(); + /* @var DLCode $obj */ + if ($obj && $obj->isRedeeamable()) { + return $obj; + } + return null; + } + + /** + * Code is active and not expired + * @return boolean + */ + public function isRedeeamable() + { + return $this->Active && + ($this->UsageCount < static::config()->get('usage_limit') || !$this->Limited) && + (!$this->Expires || $this->obj('Expires')->inFuture()); + } + + /** + * increase UsageCount – if limit is reached, also set Active to false + * @return DLCode + */ + public function increaseUsageCount() + { + $this->UsageCount++; + if ($this->Limited && $this->UsageCount >= static::config()->get('usage_limit')) { + $this->Active = false; + } + return $this; + } + + /** + * Redeem this code – called after successful form submission + * Creates/Gets a redemption object handling the secret URL + * @return DLRedemption|DataObject|null + * @throws \SilverStripe\ORM\ValidationException + */ + public function redeem() + { + if (!$this->isRedeeamable()) return null; + $this->increaseUsageCount(); + if ($this->Limited) { + $redemption = $this->Redemptions()->first(); + if (!$redemption) { + $redemption = DLRedemption::create(); + $this->Redemptions()->add($redemption); + } + } else { + $redemption = DLRedemption::create(); + $this->Redemptions()->add($redemption); + } + $this->write(); + return $redemption; + } + + public function providePermissions() + { + $perms = [ + self::EDIT_ALL => [ + 'name' => _t(__CLASS__ . '.EDIT_ALL_NAME', 'Edit download codes'), + 'category' => _t('SilverStripe\\Security\\Permission.CONTENT_CATEGORY', 'Content permissions'), + 'help' => _t(__CLASS__ . '.EDIT_ALL_HELP', 'Manage download codes.'), + 'sort' => 201 + ] + ]; + return $perms; + } + + public function canView($member = null) + { + $extended = $this->extendedCan('canView', $member); + if ($extended !== null) { + return $extended; + } + return Permission::checkMember($member, 'CMS_ACCESS_DLCodeAdmin'); + } + + public function canEdit($member = null) + { + $extended = $this->extendedCan('canEdit', $member); + if ($extended !== null) { + return $extended; + } + return Permission::checkMember($member, self::EDIT_ALL); + } + + public function canDelete($member = null) + { + return $this->canEdit($member); + } + + public function canCreate($member = null, $context = []) + { + return $this->canEdit($member); + } +} diff --git a/src/Model/DLPackage.php b/src/Model/DLPackage.php new file mode 100644 index 0000000..fce282f --- /dev/null +++ b/src/Model/DLPackage.php @@ -0,0 +1,302 @@ + 'Varchar(255)', + 'EnableZip' => 'Boolean' + ]; + + private static $has_one = [ + 'PreviewImage' => Image::class, + ]; + + private static $many_many = [ + 'Files' => File::class, + ]; + + private static $many_many_extraFields = [ + 'Files' => [ + 'Sort' => 'Int' + ] + ]; + + private static $belongs_many_many = [ + 'Code' => DLCode::class + ]; + + private static $defaults = [ + 'EnableZip' => true + ]; + + private static $summary_fields = [ + 'Title', + 'Files.Count', + 'Files.First.Title' + ]; + + private static $searchable_fields = [ + 'Title' + ]; + + /** + * @var CacheInterface + */ + private $cache; + + public function getCMSFields() + { + if (class_exists('\\Bummzack\\SortableFile\\Forms\\SortableUploadField')) { + $filesfields = SortableUploadField::create('Files', $this->fieldLabel('Files'))->setSortColumn('Sort'); + } else { + $filesfields = UploadField::create('Files', $this->fieldLabel('Files')); + } + + $fields = new FieldList( + $rootTab = new TabSet( + "Root", + $tabMain = new Tab( + 'Main', + TextField::create("Title", $this->fieldLabel('Title')), + UploadField::create('PreviewImage', $this->fieldLabel('PreviewImage')), + $filesfields, + CheckboxField::create('EnableZip', $this->fieldLabel('EnableZip')) + ) + ) + ); + + $this->extend('updateCMSFields', $fields); + return $fields; + } + + /** + * enhance field labels with custom values for summary/search fields + * @param $includerelations + * @return array + */ + public function fieldLabels($includerelations = true) + { + $labels = parent::fieldLabels($includerelations); + $labels['Files.Count'] = _t( + __CLASS__ . '.FILES_COUNT', + 'Files Count' + ); + $labels['Files.First.Title'] = _t( + __CLASS__ . '.FILES_FIRST_TITLE', + 'First File Title' + ); + return $labels; + } + + + public function providePermissions() + { + $perms = [ + self::EDIT_ALL => [ + 'name' => _t(__CLASS__ . '.EDIT_ALL_NAME', 'Edit download packages'), + 'category' => _t('SilverStripe\\Security\\Permission.CONTENT_CATEGORY', 'Content permissions'), + 'help' => _t(__CLASS__ . '.EDIT_ALL_HELP', 'Manage download packages.'), + 'sort' => 211 + ] + ]; + return $perms; + } + + public function canView($member = null) + { + $extended = $this->extendedCan('canView', $member); + if ($extended !== null) { + return $extended; + } + return Permission::checkMember($member, 'CMS_ACCESS_DLCodeAdmin'); + } + + public function canEdit($member = null) + { + $extended = $this->extendedCan('canEdit', $member); + if ($extended !== null) { + return $extended; + } + return Permission::checkMember($member, self::EDIT_ALL); + } + + public function canDelete($member = null) + { + return $this->canEdit($member); + } + + public function canCreate($member = null, $context = []) + { + return $this->canEdit($member); + } + + public function filesAreProtected() + { + $protected = true; + $checker = Injector::inst()->get(PermissionChecker::class.'.file'); + /* @var \SilverStripe\Assets\File $file */ + foreach ($this->Files() as $file) { + if ($file->CanViewType == InheritedPermissions::ANYONE) return false; + if ($file->CanViewType === InheritedPermissions::INHERIT && $file->ParentID) { + if ($checker->canView($file->ParentID, null)) return false; + } + } + return $protected; + } + + public function gridFieldValidation() + { + return $this->filesAreProtected(); + } + + public function gridFieldValidationMessage() { + return _t(__CLASS__ . '.UnprotectedFiles', 'unprotected files'); + } + + /** + * get a zipped version of all or selected package files if EnableZip is activated for the package + * + * @param array|null $filter optionally filter files to Zip, @see \SilverStripe\ORM\DataList::filter() + * @return DBFile|null + */ + public function getZippedFiles($filter = null) { + $files = is_array($filter) ? $this->Files()->filter($filter) : $this->Files(); + if (!$this->EnableZip || $files->count() < 1) { + return null; + } + if (class_exists(ZipArchive::class)) { + /* @var AssetStore $store */ + $store = Injector::inst()->get(AssetStore::class); + + $filevalue = null; + $filter = URLSegmentFilter::create(); + $filename = $filter->filter($this->Title) . ".zip"; + $tempFile = null; + + // try to get file reference from hash + $cachekey = $this->getCacheKey() . "_zip"; + $hash = $this->getCache()->get($cachekey); + + // create ZIP if not already cashed + if (!$store->exists($filename, $hash)) { + $tempPath = TEMP_PATH; + $zip = new ZipArchive(); + $tempFile = tempnam($tempPath, 'dl'); + if ($zip->open($tempFile, ZipArchive::OVERWRITE|ZipArchive::CREATE)!== TRUE) { + user_error("Could not open temp file for ZIP creation", E_USER_WARNING); + return null; + } + + /* @var \SilverStripe\Assets\File $file */ + foreach ($files as $file) { + $zip->addFromString($file->Name, $file->getString()); + } + $zip->close(); + + // write file to AssetStore + $filevalue = $store->setFromLocalFile($tempFile, $filename); + // write hash to cache + if (isset($filevalue['Hash']) && $filevalue['Hash'] != '') { + $this->getCache()->set($cachekey, $filevalue['Hash']); + } + } else { + $filevalue = [ + 'Filename' => $filename, + 'Hash' => $hash + ]; + } + + /* @var DBFile $file */ + $file = DBField::create_field('DBFile', $filevalue); + + // clean up temp file + if (file_exists($tempFile ?? '')) { + unlink($tempFile); + } + + return $file; + } + return null; + } + + /** + * get cache instance for generated file results + * @return CacheInterface + */ + public function getCache() + { + if (!$this->cache) { + $this->cache = Injector::inst()->get(CacheInterface::class . '.DLPackage_Generated'); + } + return $this->cache; + } + + /** + * get cache key, build from Title and package files + * @return string + */ + public function getCacheKey() { + $key = hash_init('sha1'); + hash_update($key, $this->ID . $this->Title); + /* @var \SilverStripe\Assets\File $file */ + foreach ($this->Files() as $file) { + hash_update($key, $file->Title); + hash_update($key, $file->getHash()); + } + return hash_final($key); + } + + /** + * remove Zip file caches on flush + * @return void + * @throws \Psr\Container\NotFoundExceptionInterface + */ + public static function flush() + { + $cache = Injector::inst()->get(CacheInterface::class . '.DLPackage_Generated'); + $cache->clear(); + } +} diff --git a/src/Model/DLPage.php b/src/Model/DLPage.php new file mode 100644 index 0000000..3bf9570 --- /dev/null +++ b/src/Model/DLPage.php @@ -0,0 +1,12 @@ +request->getVars()); + if ($redemption) { + // explicitely grant access to package files + foreach ($redemption->Code()->Package()->Files() as $file) { + /* @var File $file */ + $file->grantFile(); + } + return ["Redemption" => $redemption]; + } + return $this->httpError(404); + } + + /** + * form for code input + * @return DLRequestForm + */ + public function RequestForm() + { + return new DLRequestForm($this, 'RequestForm'); + } + + /** + * handle post data of RequestForm + * @param $data + * @param DLRequestForm $form + * @return \SilverStripe\Control\HTTPResponse|null + */ + public function submitcode($data, DLRequestForm $form) + { + $data = $form->getData(); + // check if code is existing and valid + $dlcode = DLCode::get_redeemable_code($data['Code']); + if (!$dlcode) { + $validationResult = new ValidationResult(); + $validationResult->addFieldError( + 'Code', + _t(DLRequestForm::class . 'VALIDATION_Invalid_Code', 'Invalid code') + ); + $form->setSessionValidationResult($validationResult); + $form->setSessionData($form->getData()); + return $this->redirectBack(); + } + $redemption = $dlcode->redeem(); + $url = Controller::join_links($this->owner->Link('redeem'), $redemption->getUrlParamString()); + return $this->redirect($url); + } + +} diff --git a/src/Model/DLRedemption.php b/src/Model/DLRedemption.php new file mode 100644 index 0000000..71f8533 --- /dev/null +++ b/src/Model/DLRedemption.php @@ -0,0 +1,109 @@ + 'Varchar(255)', + 'Expires' => 'Datetime' + ]; + + private static $has_one = [ + 'Code' => DLCode::class + ]; + + private static $summary_fields = [ + 'Created', + 'Expires' + ]; + + /** + * on creation generate URLSecret and Expiration date + * @return $this|DLRedemption + * @throws \Exception + */ + public function populateDefaults() + { + parent::populateDefaults(); + $this->URLSecret = bin2hex(random_bytes(32)); + $this->Expires = DBDatetime::now()->getTimestamp() + 3600 * 24 * static::config()->get('validity_days'); + return $this; + } + + /** + * check validity by expiration date + * @return boolean + */ + public function isValid() + { + return $this->Code->exists() && $this->obj('Expires')->inFuture(); + } + + /** + * create URL query string from appropriate properties, will be added to download page link + * @return string + */ + public function getUrlParamString() + { + if (!$this->isValid()) return ''; + return "?" . http_build_query(['c' => $this->Code()->ID, 'r' => $this->ID, 's' => $this->URLSecret]); + } + + /** + * Get a (valid) redemption object for given GET vars + * @param array $vars get vars from request + * @param boolean $onlyvalid check validity of object + * @return DataObject|null + */ + public static function get_by_query_params($vars, $onlyvalid = true) + { + if (!isset($vars['r']) || !isset($vars['c']) || !isset($vars['s'])) return null; + $redemption = self::get()->filter([ + 'ID' => $vars['r'], + 'Code.ID' => $vars['c'], + 'URLSecret' => $vars['s'] + ])->first(); + if ($redemption && ($redemption->isValid() || !$onlyvalid)) { + return $redemption; + } + return null; + } + + + /** + * Redemptions are viewable for all BE users with access to DLCode area + * @param $member + * @return bool|int + */ + public function canView($member = null) + { + $extended = $this->extendedCan('canView', $member); + if ($extended !== null) { + return $extended; + } + return Permission::checkMember($member, 'CMS_ACCESS_DLCodeAdmin'); + } + +} diff --git a/templates/Mhe/DownloadCodes/Model/Layout/DLPage.ss b/templates/Mhe/DownloadCodes/Model/Layout/DLPage.ss new file mode 100644 index 0000000..ec81094 --- /dev/null +++ b/templates/Mhe/DownloadCodes/Model/Layout/DLPage.ss @@ -0,0 +1,3 @@ +

$Title

+
$Content
+$RequestForm diff --git a/templates/Mhe/DownloadCodes/Model/Layout/DLPage_redeem.ss b/templates/Mhe/DownloadCodes/Model/Layout/DLPage_redeem.ss new file mode 100644 index 0000000..c2f0f13 --- /dev/null +++ b/templates/Mhe/DownloadCodes/Model/Layout/DLPage_redeem.ss @@ -0,0 +1,15 @@ +

$Title

+ +<% with $Redemption.Code.Package %> +

$Title

+ $PreviewImage.Fit(200, 200) +

Downloads

+ + <% if $ZippedFiles %> +

Download all files as ZIP

+ <% end_if %> +<% end_with %> diff --git a/tests/Model/DLCodeTest.php b/tests/Model/DLCodeTest.php new file mode 100644 index 0000000..c4f3471 --- /dev/null +++ b/tests/Model/DLCodeTest.php @@ -0,0 +1,143 @@ + true]); + $this->assertTrue($code->isRedeeamable(), 'Default active DLCode is redeemable'); + $code = DLCode::create(['Active' => false]); + $this->assertFalse($code->isRedeeamable(), 'Deactivated DLCode is not redeemable'); + + $code = DLCode::create(['Active' => true, 'Expires' => DBField::create_field('Datetime', time() + 10) ]); + $this->assertTrue($code->isRedeeamable(), 'DLCode with expiration in future is redeemable'); + $code = DLCode::create(['Active' => true, 'Expires' => DBField::create_field('Datetime', time() - 10) ]); + $this->assertFalse($code->isRedeeamable(), 'DLCode with expiration in past is not redeemable'); + + $code = DLCode::create(['Active' => true, 'Limited' => true, 'UsageCount' => 10]); + $this->assertFalse($code->isRedeeamable(), 'Limited DLCode with UsageCount above limit is not redeemable'); + $code = DLCode::create(['Active' => true, 'Limited' => false, 'UsageCount' => 10]); + $this->assertTrue($code->isRedeeamable(), 'Unlimited DLCode with UsageCount above limit is redeemable'); + } + + public function testRedeemLimited() + { + $code = $this->objFromFixture(DLCode::class, 'valid_default'); + $result = $code->redeem(); + $this->assertEquals(1, $code->UsageCount, 'Code redemption updates usage count'); + $this->assertTrue($result instanceof DLRedemption, 'Code redemption returns a DLRedemption object'); + $this->assertEquals($code->ID, $result->Code()->ID, 'New DLRedemption object is related to the code'); + $this->assertTrue($result->exists(), 'a new redemption is written'); + + $code = $this->objFromFixture(DLCode::class, 'valid_used_once'); + $redemption = $this->objFromFixture(DLRedemption::class, 'used_once'); + $result = $code->redeem(); + $this->assertEquals(2, $code->UsageCount, 'Code redemption updates usage count'); + $this->assertEquals($redemption->URLSecret, $result->URLSecret, 'An existing redemption is re-used'); + $this->assertEquals($redemption->ID, $result->ID, 'An existing redemption is re-used'); + } + + public function testRedeemUnlimited() + { + $code = $this->objFromFixture(DLCode::class, 'valid_unlimited'); + $result = $code->redeem(); + $this->assertEquals(1, $code->UsageCount, 'Code redemption updates usage count'); + $this->assertTrue($result instanceof DLRedemption, 'Code redemption returns a DLRedemption object'); + $this->assertEquals($code->ID, $result->Code()->ID, 'New DLRedemption object is related to the code'); + $this->assertNotEmpty($result->URLSecret); + $this->assertNotEmpty($result->Expires); + $this->assertTrue($result->exists(), 'a new redemption is written'); + + $code = $this->objFromFixture(DLCode::class, 'valid_unlimited_used'); + $redemption = $this->objFromFixture(DLRedemption::class, 'unlimited_used'); + $result = $code->redeem(); + $this->assertEquals(51, $code->UsageCount, 'Code redemption updates usage count'); + $this->assertTrue($result->exists(), 'a new redemption is written'); + $this->assertEquals($code->ID, $result->Code()->ID, 'New DLRedemption object is related to the code'); + $this->assertNotEquals($redemption->URLSecret, $result->URLSecret, 'a new redemption is written instead of the existing one'); + $this->assertGreaterThan($redemption->ID, $result->ID,'a new redemption is written instead of the existing one'); + } + + /** + * test validation (for creation of new DLCodes) + */ + public function testValidateUnique() { + $code = new DLCode([ 'Code' => 'abc']); + $code->write(); + $code = new DLCode([ 'Code' => 'abc1234']); + $this->assertTrue($code->validate()->isValid()); + $code = new DLCode([ 'Code' => 'abc']); + $this->assertFalse($code->validate()->isValid()); + // validation is always case-insensitive: + $code = new DLCode([ 'Code' => 'aBC']); + $this->assertFalse($code->validate()->isValid()); + } + + /** + * test getting a redemption for code as entered by a user + */ + public function testGetRedeemable() { + Config::modify()->set(DLCode::class, 'case_sensitive', true); + $code = DLCode::get_redeemable_code('VALIDCODE'); + $this->assertNotEmpty($code); + $this->assertTrue($code->isRedeeamable()); + $code = DLCode::get_redeemable_code('vAlIDcoDE'); + $this->assertEmpty($code); + + Config::modify()->set(DLCode::class, 'case_sensitive', false); + $code = DLCode::get_redeemable_code('VALIDCODE'); + $this->assertNotEmpty($code); + $this->assertTrue($code->isRedeeamable()); + $code = DLCode::get_redeemable_code('vAlIDcoDE'); + $this->assertNotEmpty($code); + $this->assertTrue($code->isRedeeamable()); + } + + public function testAutoGenerate() { + Config::modify()->set(DLCode::class, 'autogenerate_chars', 'abcde'); + Config::modify()->set(DLCode::class, 'autogenerate_length', 6); + for ($i = 0; $i < 5; $i++) { + $code = DLCode::autoGenerate([]); + $this->assertTrue($code->Active); + $this->assertTrue($code->Limited); + $codecheck = DLCode::get()->filter(['Code' => $code->Code]); + $this->assertEquals(1, $codecheck->count()); + $this->assertEquals($code->ID, $codecheck->first()->ID); + // hard to test random values reliably, but let’s give it a try ... + $this->assertMatchesRegularExpression('!^[abcde]{6}$!', $code->Code); + } + Config::modify()->set(DLCode::class, 'autogenerate_chars', 'ABC012'); + Config::modify()->set(DLCode::class, 'autogenerate_length', 4); + for ($i = 0; $i < 5; $i++) { + $code = DLCode::autoGenerate([]); + $this->assertTrue($code->Active); + $this->assertTrue($code->Limited); + $codecheck = DLCode::get()->filter(['Code' => $code->Code]); + $this->assertEquals(1, $codecheck->count()); + $this->assertEquals($code->ID, $codecheck->first()->ID); + // hard to test random values reliably, but let’s give it a try ... + $this->assertMatchesRegularExpression('!^[ABC012]{4}$!', $code->Code); + } + } +} diff --git a/tests/Model/DLCodeTest.yml b/tests/Model/DLCodeTest.yml new file mode 100644 index 0000000..4aadf5b --- /dev/null +++ b/tests/Model/DLCodeTest.yml @@ -0,0 +1,56 @@ +Mhe\DownloadCodes\Model\DLCode: + valid_default: + Code: "VALIDCODE" + Expires: + Active: 1 + Limited: 1 + UsageCount: 0 + valid_used_once: + Code: "ONCE_USED" + Expires: + Active: 1 + Limited: 1 + UsageCount: 1 + valid_unlimited: + Code: "FREE" + Expires: + Active: 1 + Limited: 0 + UsageCount: 0 + valid_unlimited_used: + Code: "BIRD" + Expires: + Active: 1 + Limited: 0 + UsageCount: 50 + deactivated: + Code: "DEACTIVATED" + Expires: + Active: 0 + Limited: 1 + UsageCount: 0 + usedlimit: + Code: "USEDLIMIT" + Expires: + Active: 1 + Limited: 1 + UsageCount: 20 + expired: + Code: "EXPIRED" + Expires: "2020-01-01T00:00" + Active: 1 + Limited: 1 + UsageCount: 0 + expire_future: + Code: "EXPIRE_FUTURE" + Expires: "2099-01-01T00:00" + Active: 1 + Limited: 1 + UsageCount: 0 +Mhe\DownloadCodes\Model\DLRedemption: + used_once: + URLSecret: "a123" + Code: =>Mhe\DownloadCodes\Model\DLCode.valid_used_once + unlimited_used: + URLSecret: "a789" + Code: =>Mhe\DownloadCodes\Model\DLCode.valid_unlimited_used diff --git a/tests/Model/DLPackageTest.php b/tests/Model/DLPackageTest.php new file mode 100644 index 0000000..aa3270f --- /dev/null +++ b/tests/Model/DLPackageTest.php @@ -0,0 +1,96 @@ +exclude('ClassName', Folder::class); + foreach ($files as $file) { + try { + $sourcePath = __DIR__ . '/../downloads/' . $file->Name; + $file->setFromLocalFile($sourcePath, $file->Filename); + $file->publishSingle(); + } catch (\InvalidArgumentException $e) { } + } + } + + public function tearDown(): void + { + parent::tearDown(); + } + + /** + * check if all files are protected + * @return void + */ + public function testCheckFilesAccess() { + $package = new DLPackage(); + $this->assertTrue($package->filesAreProtected()); + $package->Files()->add($this->objFromFixture(Image::class, 'protectedimage1')); + $package->Files()->add($this->objFromFixture(Image::class, 'protectedimage2')); + $this->assertTrue($package->filesAreProtected()); + $package->Files()->add($this->objFromFixture(Image::class, 'publicimage1')); + $this->assertFalse($package->filesAreProtected()); + + $package->Files()->removeAll(); + $package->Files()->add($this->objFromFixture(Image::class, 'publicimage2')); + $this->assertFalse($package->filesAreProtected()); + $package->Files()->add($this->objFromFixture(Image::class, 'protectedimage1')); + $package->Files()->add($this->objFromFixture(Image::class, 'protectedimage2')); + $this->assertFalse($package->filesAreProtected()); + } + + public function testGetCacheKey() { + $hashs = []; + /* @var DLPackage $package */ + $package = $this->objFromFixture(DLPackage::class, 'package1'); + $hashs[] = $package->getCacheKey(); + // modified package title + $package->Title = $package->Title . " modified"; + $hashs[] = $package->getCacheKey(); + // modified file title + /* @var File $file */ + $file = $package->Files()->first(); + $file->Title = $file->Title . " modified Title"; + $file->write(); + $hashs[] = $package->getCacheKey(); + // modified files + $package->Files()->remove($package->Files()->first()); + $hashs[] = $package->getCacheKey(); + // assert unique and valid hashs + $this->assertEquals(4, count(array_unique($hashs))); + $this->assertEquals(4, count(array_filter($hashs, fn($hash) => strlen($hash) == 40))); + } + + public function testGeneratedZip() { + /* @var DLPackage $package */ + $package = $this->objFromFixture(DLPackage::class, 'package1'); + $this->assertEquals('Test Package', $package->Title); + + /* @var \SilverStripe\Assets\Storage\DBFile $zip */ + $zip = $package->getZippedFiles(); + $this->assertTrue($zip->exists()); + $this->assertEquals(40, strlen($zip->Hash)); + $this->assertEquals('application/zip', $zip->getMimeType()); + $this->assertEquals('test-package.zip', $zip->Filename ); + + $package->EnableZip = false; + $this->assertEmpty($package->getZippedFiles()); + } +} diff --git a/tests/Model/DLPackageTest.yml b/tests/Model/DLPackageTest.yml new file mode 100644 index 0000000..0c590d7 --- /dev/null +++ b/tests/Model/DLPackageTest.yml @@ -0,0 +1,61 @@ +Mhe\DownloadCodes\Model\DLCode: + valid_default: + Code: "VALIDCODE" + Expires: + Active: 1 + Limited: 1 + UsageCount: 0 +SilverStripe\Assets\Folder: + publicfolder: + Title: "Public Folder" + CanViewType: Anyone + protectedfolder: + Title: "Protected Folder" + CanViewType: OnlyTheseUsers +SilverStripe\Assets\File: + download1: + FileFilename: download1.mp3 + CanViewType: OnlyTheseUsers + FileHash: c129504a352cce6a6e80f3ca6c95ab85b14b9b62 + Name: download1.mp3 + download2: + FileFilename: download2.wav + CanViewType: OnlyTheseUsers + FileHash: 6e11ccf096e93f03dae7a3ce00d42a64cda3d739 + Name: download2.wav +SilverStripe\Assets\Image: + publicimage1: + FileFilename: publicfolder/publicimage1.jpg + Parent: =>SilverStripe\Assets\Folder.publicfolder + Name: publicimage1.jpg + CanViewType: Inherit + publicimage2: + FileFilename: publicfolder/publicimage2.jpg + Parent: =>SilverStripe\Assets\Folder.protectedfolder + Name: publicimage2.jpg + CanViewType: Anyone + protectedimage1: + FileFilename: publicfolder/protectedimage1.jpg + Parent: =>SilverStripe\Assets\Folder.protectedfolder + Name: protectedimage1.jpg + CanViewType: Inherit + protectedimage2: + FileFilename: publicfolder/protectedimage2.jpg + Parent: =>SilverStripe\Assets\Folder.publicfolder + Name: protectedimage2.jpg + CanViewType: OnlyTheseUsers + preview_image: + Title: "Preview image" + FileFilename: preview.jpg + FileHash: 347930464c806c3a70bbf87a3a741572de7c529f + Name: preview.jpg +Mhe\DownloadCodes\Model\DLPackage: + package1: + Title: "Test Package" + EnableZip: true + PreviewImage: =>SilverStripe\Assets\Image.preview_image + Files: + - =>SilverStripe\Assets\File.download1 + - =>SilverStripe\Assets\File.download2 + + diff --git a/tests/Model/DLPageTest.php b/tests/Model/DLPageTest.php new file mode 100644 index 0000000..930b296 --- /dev/null +++ b/tests/Model/DLPageTest.php @@ -0,0 +1,166 @@ +update('alternate_base_url', 'http://www.mysite.com/'); + + // setup test theme + $themeBaseDir = realpath(__DIR__ . '/..'); + if (strpos($themeBaseDir, BASE_PATH) === 0) { + $themeBaseDir = substr($themeBaseDir, strlen(BASE_PATH)); + } + SSViewer::config()->set('theme_enabled', true); + SSViewer::set_themes([$themeBaseDir . '/themes/' . self::$testtheme, '$default']); + + /** @var Page $page */ + foreach (Page::get() as $page) { + $page->publishSingle(); + } + + // setup test file storage + TestAssetStore::activate('DownloadFiles'); + /** @var File $file */ + $files = File::get()->exclude('ClassName', Folder::class); + foreach ($files as $file) { + $sourcePath = __DIR__ . '/../downloads/' . $file->Name; + $file->setFromLocalFile($sourcePath, $file->Filename); + $file->publishSingle(); + } + } + + protected function tearDown(): void + { + TestAssetStore::reset(); + parent::tearDown(); + } + + public function testFormIsPresent() + { + $this->get('download'); + $form = $this->cssParser()->getBySelector('form#DLRequestForm_RequestForm'); + $this->assertNotEmpty($form); + } + + public function testFormErrorForInValidCodes() + { + $this->get('download'); + $this->submitForm("DLRequestForm_RequestForm", "action_submitcode", array("Code" => "unknown code")); + $this->assertPartialMatchBySelector('.message', 'Invalid code', 'Unknown code is rejected'); + + $this->get('download'); + $this->submitForm("DLRequestForm_RequestForm", "action_submitcode", array("Code" => "DEACTIVATED")); + $this->assertPartialMatchBySelector('.message', 'Invalid code', 'Deactivated code is rejected'); + + $this->get('download'); + $this->submitForm("DLRequestForm_RequestForm", "action_submitcode", array("Code" => "USEDLIMIT")); + $this->assertPartialMatchBySelector('.message', 'Invalid code', 'Code exceeding limit is rejected'); + + $this->get('download'); + $this->submitForm("DLRequestForm_RequestForm", "action_submitcode", array("Code" => "EXPIRED")); + $this->assertPartialMatchBySelector('.message', 'Invalid code', 'Expired code is rejected'); + } + + private function assertFormSuccess(HTTPResponse $response, $title) { + $this->assertStringStartsWith('download/redeem', $this->mainSession->lastUrl(), 'Valid code redirects to Redeem action'); + $this->assertEquals(200, $response->getStatusCode()); + // contains title of package + $this->assertPartialMatchBySelector('h2', $title); + } + + public function testFormForValidCode() + { + $this->get('download'); + $response = $this->submitForm("DLRequestForm_RequestForm", "action_submitcode", array("Code" => "VALIDCODE")); + $this->assertFormSuccess($response, 'Two Files'); + + $this->get('download'); + $response = $this->submitForm("DLRequestForm_RequestForm", "action_submitcode", array("Code" => "EXPIRE_FUTURE")); + $this->assertFormSuccess($response, 'Two Files'); + + $this->get('download'); + $response = $this->submitForm("DLRequestForm_RequestForm", "action_submitcode", array("Code" => "FREE")); + $this->assertFormSuccess($response, 'Two Files'); + } + + public function testFormForValidCodeWithWhitespace() { + DLCode::config()->set('strip_whitespace', false); + $this->get('download'); + $response = $this->submitForm("DLRequestForm_RequestForm", "action_submitcode", array("Code" => " FREE ")); + $this->assertPartialMatchBySelector('.message', 'Invalid code', 'Unknown code is rejected'); + + DLCode::config()->set('strip_whitespace', true); + $this->get('download'); + $response = $this->submitForm("DLRequestForm_RequestForm", "action_submitcode", array("Code" => " FREE ")); + $this->assertFormSuccess($response, 'Two Files'); + } + + public function testRedeemPage() + { + $c = $this->idFromFixture(DLCode::class, 'valid_unlimited'); + $r = $this->idFromFixture(DLRedemption::class, 'valid'); + $this->get("download/redeem?c=$c&r=$r&s=a1234567890b"); + + // page contains title of package + $this->assertPartialMatchBySelector('h2', 'Two Files'); + + // page contains preview image + $img = $this->cssParser()->getBySelector('img')[0]; + $this->assertEquals('/assets/DownloadFiles/preview.jpg', $img['src']); + + // page contains label + links for all package files + $this->assertExactHTMLMatchBySelector('ul.downloads a', + ['Download 1', + 'Download 2']); + } + + public function testRedeemPageInvalid() + { + $c = $this->idFromFixture(DLCode::class, 'valid_unlimited'); + $r = $this->idFromFixture(DLRedemption::class, 'valid'); + + $response = $this->get("download/redee"); + $this->assertEquals(404, $response->getStatusCode()); + $response = $this->get("download/redeem?c=$c&r=999&s=a1234567890b"); + $this->assertEquals(404, $response->getStatusCode()); + $response = $this->get("download/redeem?c=999&r=$r&s=a1234567890b"); + $this->assertEquals(404, $response->getStatusCode()); + $response = $this->get("download/redeem?c=$c&r=$r&s=aaaa12345"); + $this->assertEquals(404, $response->getStatusCode()); + } + + public function testFileAccess() + { + // File is protected + $response = $this->get("/assets/c129504a35/download1.mp3"); + $this->assertEquals(404, $response->getStatusCode()); + + // File access is granted + $c = $this->idFromFixture(DLCode::class, 'valid_unlimited'); + $r = $this->idFromFixture(DLRedemption::class, 'valid'); + $this->get("download/redeem?c=$c&r=$r&s=a1234567890b"); + $response = $this->get("/assets/c129504a35/download1.mp3"); + $this->assertEquals(200, $response->getStatusCode()); + } + +} diff --git a/tests/Model/DLPageTest.yml b/tests/Model/DLPageTest.yml new file mode 100644 index 0000000..ce3490f --- /dev/null +++ b/tests/Model/DLPageTest.yml @@ -0,0 +1,85 @@ +SilverStripe\Assets\Folder: + folder: + Title: folder +SilverStripe\Assets\File: + download1: + FileFilename: download1.mp3 + CanViewType: OnlyTheseUsers + FileHash: c129504a352cce6a6e80f3ca6c95ab85b14b9b62 + Name: download1.mp3 + Title: Download 1 + download2: + FileFilename: download2.wav + CanViewType: OnlyTheseUsers + FileHash: 6e11ccf096e93f03dae7a3ce00d42a64cda3d739 + Name: download2.wav + Title: Download 2 +SilverStripe\Assets\Image: + preview_image: + Title: "Preview image" + FileFilename: preview.jpg + FileHash: 347930464c806c3a70bbf87a3a741572de7c529f + Name: preview.jpg +Mhe\DownloadCodes\Model\DLPage: + download: + Title: Download + URLSegment: download +Mhe\DownloadCodes\Model\DLPackage: + downloads: + Title: 'Two Files' + PreviewImage: =>SilverStripe\Assets\Image.preview_image + Files: + - =>SilverStripe\Assets\File.download1: + Label: "MP3-File" + Sort: 1 + - =>SilverStripe\Assets\File.download2: + Label: "WAV-File" + Sort: 2 +Mhe\DownloadCodes\Model\DLCode: + valid_default: + Code: "VALIDCODE" + Expires: + Active: 1 + Limited: 1 + UsageCount: 0 + Package: =>Mhe\DownloadCodes\Model\DLPackage.downloads + valid_unlimited: + Code: "FREE" + Expires: + Active: 1 + Limited: 0 + UsageCount: 0 + Package: =>Mhe\DownloadCodes\Model\DLPackage.downloads + deactivated: + Code: "DEACTIVATED" + Expires: + Active: 0 + Limited: 1 + UsageCount: 0 + Package: =>Mhe\DownloadCodes\Model\DLPackage.downloads + usedlimit: + Code: "USEDLIMIT" + Expires: + Active: 1 + Limited: 1 + UsageCount: 20 + Package: =>Mhe\DownloadCodes\Model\DLPackage.downloads + expired: + Code: "EXPIRED" + Expires: "2020-01-01T00:00" + Active: 1 + Limited: 1 + UsageCount: 0 + Package: =>Mhe\DownloadCodes\Model\DLPackage.downloads + expire_future: + Code: "EXPIRE_FUTURE" + Expires: "2099-01-01T00:00" + Active: 1 + Limited: 1 + UsageCount: 0 + Package: =>Mhe\DownloadCodes\Model\DLPackage.downloads +Mhe\DownloadCodes\Model\DLRedemption: + valid: + URLSecret: "a1234567890b" + Code: =>Mhe\DownloadCodes\Model\DLCode.valid_unlimited + Expires: "2099-01-01T00:00:00" diff --git a/tests/Model/DLRedemptionTest.php b/tests/Model/DLRedemptionTest.php new file mode 100644 index 0000000..0736342 --- /dev/null +++ b/tests/Model/DLRedemptionTest.php @@ -0,0 +1,75 @@ +assertMatchesRegularExpression("![a-f0-9]{64}!", $redemption->URLSecret); + $this->assertEquals( strtotime('2022-01-22T08:15:00'), $redemption->Expires); + // modified expiration + Config::modify()->set(DLRedemption::class, 'validity_days', 14); + $redemption = DLRedemption::create(); + $this->assertEquals( strtotime('2022-01-29T08:15:00'), $redemption->Expires); + } + + public function testValid() + { + $redemption = $this->objFromFixture(DLRedemption::class, 'valid'); + $this->assertTrue($redemption->isValid()); + $redemption = $this->objFromFixture(DLRedemption::class, 'expired_today'); + $this->assertFalse($redemption->isValid()); + $redemption = $this->objFromFixture(DLRedemption::class, 'expired'); + $this->assertFalse($redemption->isValid()); + $redemption = $this->objFromFixture(DLRedemption::class, 'incomplete'); + $this->assertFalse($redemption->isValid()); + } + + public function testUrlParamString() + { + $code = $this->objFromFixture(DLCode::class, 'valid_unlimited'); + $redemption = $this->objFromFixture(DLRedemption::class, 'valid'); + $this->assertEquals("?c={$code->ID}&r={$redemption->ID}&s=a123", $redemption->getUrlParamString()); + } + + public function testGetByQueryParams() + { + $c = $this->idFromFixture(DLCode::class, 'valid_unlimited'); + $r = $this->idFromFixture(DLRedemption::class, 'valid'); + // get redemption with correct parameters + $this->assertEquals($r, DLRedemption::get_by_query_params(['c' => $c, 'r' => $r, 's' => 'a123'])->ID); + // wrong parameters + $this->assertEmpty(DLRedemption::get_by_query_params(['c' => $c + 1, 'r' => $r, 's' => 'a123'])); + $this->assertEmpty(DLRedemption::get_by_query_params(['c' => $c, 'r' => $r + 1, 's' => 'a123'])); + $this->assertEmpty(DLRedemption::get_by_query_params(['c' => $c, 'r' => $r, 's' => 'b123'])); + + // expired redemption: + $r = $this->idFromFixture(DLRedemption::class, 'expired'); + $this->assertEmpty(DLRedemption::get_by_query_params(['c' => $c, 'r' => $r, 's' => 'c123'])); + // ignoring validity check + $this->assertEquals($r, DLRedemption::get_by_query_params(['c' => $c, 'r' => $r, 's' => 'c123'], false)->ID); + } +} diff --git a/tests/Model/DLRedemptionTest.yml b/tests/Model/DLRedemptionTest.yml new file mode 100644 index 0000000..0b399e9 --- /dev/null +++ b/tests/Model/DLRedemptionTest.yml @@ -0,0 +1,29 @@ +Mhe\DownloadCodes\Model\DLCode: + valid_default: + Code: "VALIDCODE" + Expires: + Active: 1 + Limited: 1 + UsageCount: 0 + valid_unlimited: + Code: "FREE" + Expires: + Active: 1 + Limited: 0 + UsageCount: 0 +Mhe\DownloadCodes\Model\DLRedemption: + valid: + URLSecret: "a123" + Code: =>Mhe\DownloadCodes\Model\DLCode.valid_unlimited + Expires: "2022-02-20T00:00:00" + expired_today: + URLSecret: "b123" + Code: =>Mhe\DownloadCodes\Model\DLCode.valid_unlimited + Expires: "2022-01-15T00:00:00" + expired: + URLSecret: "c123" + Code: =>Mhe\DownloadCodes\Model\DLCode.valid_unlimited + Expires: "2022-01-10T00:00:00" + incomplete: + URLSecret: "d123" + Expires: "2022-02-20T00:00:00" diff --git a/tests/downloads/download1.mp3 b/tests/downloads/download1.mp3 new file mode 100644 index 0000000..f177a2a Binary files /dev/null and b/tests/downloads/download1.mp3 differ diff --git a/tests/downloads/download2.wav b/tests/downloads/download2.wav new file mode 100644 index 0000000..8b9b6e8 Binary files /dev/null and b/tests/downloads/download2.wav differ diff --git a/tests/downloads/preview.jpg b/tests/downloads/preview.jpg new file mode 100644 index 0000000..14f0317 Binary files /dev/null and b/tests/downloads/preview.jpg differ diff --git a/tests/themes/test-downloads/templates/Layout/Page.ss b/tests/themes/test-downloads/templates/Layout/Page.ss new file mode 100644 index 0000000..3135829 --- /dev/null +++ b/tests/themes/test-downloads/templates/Layout/Page.ss @@ -0,0 +1,2 @@ +

$Title

+$Content diff --git a/tests/themes/test-downloads/templates/Mhe/DownloadCodes/Model/Layout/DLPage.ss b/tests/themes/test-downloads/templates/Mhe/DownloadCodes/Model/Layout/DLPage.ss new file mode 100644 index 0000000..26984c0 --- /dev/null +++ b/tests/themes/test-downloads/templates/Mhe/DownloadCodes/Model/Layout/DLPage.ss @@ -0,0 +1,2 @@ +

$Title

+$RequestForm diff --git a/tests/themes/test-downloads/templates/Mhe/DownloadCodes/Model/Layout/DLPage_redeem.ss b/tests/themes/test-downloads/templates/Mhe/DownloadCodes/Model/Layout/DLPage_redeem.ss new file mode 100644 index 0000000..085be14 --- /dev/null +++ b/tests/themes/test-downloads/templates/Mhe/DownloadCodes/Model/Layout/DLPage_redeem.ss @@ -0,0 +1,10 @@ +

$Title

+<% with $Redemption.Code.Package %> +

$Title

+ $PreviewImage + +<% end_with %> diff --git a/tests/themes/test-downloads/templates/Page.ss b/tests/themes/test-downloads/templates/Page.ss new file mode 100644 index 0000000..fbb02d3 --- /dev/null +++ b/tests/themes/test-downloads/templates/Page.ss @@ -0,0 +1,10 @@ + + + <% base_tag %> + <% if $MetaTitle %>$MetaTitle<% else %>$Title<% end_if %> » $SiteConfig.Title + + + +$Layout + +