From d1ea74e40d7558e2ba8fef4c0fa5cecd0a98650b Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Mon, 19 Oct 2015 17:27:27 +1300 Subject: [PATCH] API Implement AssetField to edit DBFile fields --- _config/uploadfield.yml | 14 - .../Field_types/01_Common_Subclasses.md | 3 +- .../03_Forms/Field_types/05_UploadField.md | 4 + .../03_Forms/Field_types/06_AssetField.md | 225 +++++ .../14_Files/01_File_Management.md | 8 +- docs/en/04_Changelogs/4.0.0.md | 2 + filesystem/Upload.php | 114 ++- filesystem/storage/DBFile.php | 32 +- forms/AssetField.php | 766 ++++++++++++++++++ forms/FormField.php | 10 +- forms/UploadField.php | 24 +- javascript/UploadField_downloadtemplate.js | 9 +- javascript/UploadField_uploadtemplate.js | 26 +- model/fieldtypes/CompositeDBField.php | 6 +- templates/AssetField.ss | 48 ++ templates/Includes/AssetField_FileButtons.ss | 2 + tests/filesystem/UploadTest.php | 44 +- tests/forms/HtmlEditorFieldTest.php | 2 +- tests/forms/uploadfield/AssetFieldTest.php | 404 +++++++++ tests/forms/uploadfield/AssetFieldTest.yml | 17 + tests/model/DataDifferencerTest.php | 2 +- tests/model/ImageTest.php | 2 +- view/Requirements.php | 3 - 23 files changed, 1650 insertions(+), 117 deletions(-) delete mode 100644 _config/uploadfield.yml create mode 100644 docs/en/02_Developer_Guides/03_Forms/Field_types/06_AssetField.md create mode 100644 forms/AssetField.php create mode 100644 templates/AssetField.ss create mode 100644 templates/Includes/AssetField_FileButtons.ss create mode 100644 tests/forms/uploadfield/AssetFieldTest.php create mode 100644 tests/forms/uploadfield/AssetFieldTest.yml diff --git a/_config/uploadfield.yml b/_config/uploadfield.yml deleted file mode 100644 index ce98a07a4..000000000 --- a/_config/uploadfield.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: uploadfield ---- -UploadField: - defaultConfig: - autoUpload: true - allowedMaxFileNumber: - canUpload: true - canAttachExisting: 'CMS_ACCESS_AssetAdmin' - canPreviewFolder: true - previewMaxWidth: 80 - previewMaxHeight: 60 - uploadTemplateName: 'ss-uploadfield-uploadtemplate' - downloadTemplateName: 'ss-uploadfield-downloadtemplate' - overwriteWarning: true # Warning before overwriting existing file (only relevant when Upload: replaceFile is true) diff --git a/docs/en/02_Developer_Guides/03_Forms/Field_types/01_Common_Subclasses.md b/docs/en/02_Developer_Guides/03_Forms/Field_types/01_Common_Subclasses.md index 951578f22..191dda347 100644 --- a/docs/en/02_Developer_Guides/03_Forms/Field_types/01_Common_Subclasses.md +++ b/docs/en/02_Developer_Guides/03_Forms/Field_types/01_Common_Subclasses.md @@ -53,7 +53,8 @@ doesn't necessarily have any visible styling. ## Files * `[api:FileField]`: Simple file upload dialog. - * `[api:UploadField]`: File uploads through HTML5 features, including upload progress, preview and relationship management. + * `[api:UploadField]`: Upload to a `[api:File]` record, including upload progress, preview and relationship management. + * `[api:AssetField]`: Upload to a `[api:DBFile]` database field. Very similar to UploadField ## Relations diff --git a/docs/en/02_Developer_Guides/03_Forms/Field_types/05_UploadField.md b/docs/en/02_Developer_Guides/03_Forms/Field_types/05_UploadField.md index a345732ac..3aa64553b 100644 --- a/docs/en/02_Developer_Guides/03_Forms/Field_types/05_UploadField.md +++ b/docs/en/02_Developer_Guides/03_Forms/Field_types/05_UploadField.md @@ -6,6 +6,10 @@ The UploadField will let you upload one or multiple files of all types, includin But that's not all it does - it will also link the uploaded file(s) to an existing relation and let you edit the linked files as well. That makes it flexible enough to sometimes even replace the GridField, like for instance in creating and managing a simple gallery. + +The field automatically creates a `File` record for each uploaded file. +In order to associate uploaded files directly to a `DataObject` via the +`[api:DBFile]` database field, please use [AssetField](AssetField). ## Usage diff --git a/docs/en/02_Developer_Guides/03_Forms/Field_types/06_AssetField.md b/docs/en/02_Developer_Guides/03_Forms/Field_types/06_AssetField.md new file mode 100644 index 000000000..e479a8fc9 --- /dev/null +++ b/docs/en/02_Developer_Guides/03_Forms/Field_types/06_AssetField.md @@ -0,0 +1,225 @@ +# AssetField + +## Introduction + +This form field can be used to upload files into SilverStripe's asset store. +It associates a file directly to a `DataObject` through the `[api:DBFile]` database field. +Saving the file association directly in a `DataObject` (as opposed to a relation) +can simplify data management and publication. + +In order to create `[api:File]` records to contain uploaded files, +please use the [AssetField](AssetField) instead. + +## Usage + +The field expects to save into a `DataObject` record with a `DBFile` +property matching the name of the field itself. + + +```php + class Team extends DataObject { + + private static $db = array( + 'BannerImage' => 'DBFile' + ); + + function getCMSFields() { + $fields = parent::getCMSFields(); + + $fields->addFieldToTab( + 'Root.Upload', + $assetField = new AssetField( + $name = 'BannerImage', + $title = 'Upload a banner' + ) + ); + // Restrict validator to include only supported image formats + $assetField->setAllowedFileCategories('image/supported'); + + return $fields; + } + } +``` + +## Validation + +Although images are uploaded and stored on the filesystem immediately after selection, +the value (or values) of this field will not be written to any related record until the +record is saved and successfully validated. However, any invalid records will still +persist across form submissions until explicitly removed or replaced by the user. + +Care should be taken as invalid files may remain within the filesystem until explicitly removed. + +## Configuration + +### Overview + +AssetField can either be configured on an instance level with the various getProperty +and setProperty functions, or globally by overriding the YAML defaults. + +See the [Configuration Reference](uploadfield#configuration-reference) section for possible values. + +Example: mysite/_config/uploadfield.yml + + :::yaml + after: framework#uploadfield + --- + AssetField: + defaultConfig: + canUpload: false + + +### Set a custom folder + +This example will save all uploads in the `customfolder` in the configured assets store root (normally under 'assets') +If the folder doesn't exist, it will be created. + + :::php + $fields->addFieldToTab( + 'Root.Upload', + $assetField = new AssetField( + $name = 'GalleryImage', + $title = 'Please upload an image' + ) + ); + $assetField->setFolderName('customfolder'); + + +### Limit the allowed filetypes + +`AllowedExtensions` defaults to the `File.allowed_extensions` configuration setting, +but can be overwritten for each AssetField: + + + :::php + $assetField->setAllowedExtensions(array('jpg', 'jpeg', 'png', 'gif')); + + +Entire groups of file extensions can be specified in order to quickly limit types to known file categories. +This can be done by using file category names, which are defined via the `File.app_categories` config. This +list could be extended with any custom categories. + +The built in categories are: + +| File category | Example extensions | +|-----------------|--------------------| +| archive | zip, gz, rar | +| audio | mp3, wav, ogg | +| document | doc, txt, pdf | +| flash | fla, swf | +| image | jpg, tiff, ps | +| image/supported | jpg, gif, png | +| video | mkv, avi, mp4 | + +Note that although all image types are included in the 'image' category, only images that are in the +'images/supported' list are compatible with the SilverStripe image manipulations API. Other types +can be uploaded, but cannot be resized. + + :::php + $assetField->setAllowedFileCategories('image/supported'); + + +This will limit files to the the compatible image formats: jpg, jpeg, gif, and png. + +`AllowedExtensions` can also be set globally via the +[YAML configuration](/developer_guides/configuration/configuration/#configuration-yaml-syntax-and-rules), +for example you may add the following into your mysite/_config/config.yml: + + + :::yaml + File: + allowed_extensions: + - 7zip + - xzip + + +### Limit the maximum file size + +`AllowedMaxFileSize` is by default set to the lower value of the 2 php.ini configurations: +`upload_max_filesize` and `post_max_size`. The value is set as bytes. + +NOTE: this only sets the configuration for your AssetField, this does NOT change your +server upload settings, so if your server is set to only allow 1 MB and you set the +AssetField to 2 MB, uploads will not work. + + + :::php + $sizeMB = 2; // 2 MB + $size = $sizeMB * 1024 * 1024; // 2 MB in bytes + $this->getValidator()->setAllowedMaxFileSize($size); + + +You can also specify a default global maximum file size setting in your config for different file types. +This is overridden when specifying the max allowed file size on the AssetField instance. + + + :::yaml + Upload_Validator: + default_max_file_size: + '[image]': '1m' + '[document]': '5m' + 'jpeg': 2000 + + +### Preview dimensions + +Set the dimensions of the image preview. By default the max width is set to 80 and the max height is set to 60. + + + :::php + $assetField->setPreviewMaxWidth(100); + $assetField->setPreviewMaxHeight(100); + + + +### Disable uploading of new files + +Alternatively, you can force the user to only specify already existing files in the file library + + + :::php + $assetField->setCanUpload(false); + + +### Automatic or manual upload + +By default, the AssetField will try to automatically upload all selected files. Setting the `autoUpload` +property to false, will present you with a list of selected files that you can then upload manually one by one: + + + :::php + $assetField->setAutoUpload(false); + + +### Change Detection + +The CMS interface will automatically notify the form containing +an AssetField instance of changes, such as a new upload, +or the removal of an existing upload (through a `dirty` event). +The UI can then choose an appropriate response (e.g. highlighting the "save" button). +If the AssetField doesn't save into a relation, there's technically no saveable change +(the upload has already happened), which is why this feature can be disabled on demand. + + + :::php + $assetField->setConfig('changeDetection', false); + +## Configuration Reference + + * `setAllowedFileExtensions`: (array) List of file extensions allowed. + * `setAllowedFileCategories`: (array|string) List of types of files allowed. May be any number of + categories as defined in `File.app_categories` config. + * `setAutoUpload`: (boolean) Should the field automatically trigger an upload once a file is selected? + * `setCanPreviewFolder`: (boolean|string) Can the user preview the folder files will be saved into? + String values are interpreted as permission codes. + * `setCanUpload`: (boolean|string) Can the user upload new files, or just select from existing files. + String values are interpreted as permission codes. + * `setDownloadTemplateName`: (string) javascript template used to display already uploaded files, see + javascript/UploadField_downloadtemplate.js. + * `setPreviewMaxWidth`: (int). + * `setPreviewMaxHeight`: (int). + * `setTemplateFileButtons`: (string) Template name to use for the file buttons. + * `setUploadTemplateName`: (string) javascript template used to display uploading files, see + javascript/UploadField_uploadtemplate.js. + * `setCanPreviewFolder`: (boolean|string) Is the upload folder visible to uploading users? String values + are interpreted as permission codes. diff --git a/docs/en/02_Developer_Guides/14_Files/01_File_Management.md b/docs/en/02_Developer_Guides/14_Files/01_File_Management.md index 316dc02bc..532b75295 100644 --- a/docs/en/02_Developer_Guides/14_Files/01_File_Management.md +++ b/docs/en/02_Developer_Guides/14_Files/01_File_Management.md @@ -153,4 +153,10 @@ You may also notice the 'Sync files' button (highlighted below). This button all ## Upload -Files can be managed through a `FileField` or an `UploadField`. The `[api:FileField]` class provides a simple HTML input with a type of "file", whereas an `[api:UploadField]` provides a much more feature-rich field (including AJAX-based uploads, previews, relationship management and file data management). See [`Reference - UploadField`](/developer_guides/forms/field_types/uploadfield) for more information about how to use the `UploadField` class. +Files can be managed through forms in three ways: + + * `[api:FileField]`: provides a simple HTML input with a type of "file". + * [UploadField](/developer_guides/forms/field_types/uploadfield): more feature-rich field ( + including AJAX-based uploads, previews, relationship management and file data management). + * [AssetField](/developer_guides/forms/field_types/assetfield): Similar to UploadField, + but operates on a `[api:DBFile]` database field instead of a `[api:File]` record. diff --git a/docs/en/04_Changelogs/4.0.0.md b/docs/en/04_Changelogs/4.0.0.md index 3b6e3683d..dd53a255c 100644 --- a/docs/en/04_Changelogs/4.0.0.md +++ b/docs/en/04_Changelogs/4.0.0.md @@ -20,6 +20,7 @@ the `DataObject::ID` in a `data-fileid` property, or via shortcodes. This is necessary because file urls are no longer able to identify assets. * Extension point `HtmlEditorField::processImage` has been removed, and moved to `Image::regenerateImageHTML` + * `Upload::load` now stores assets directly without saving into a `File` dataobject. ## New API @@ -31,6 +32,7 @@ cache or combined files). * `Requirements_Minifier` API can be used to declare any new mechanism for minifying combined required files. By default this api is provided by the `JSMinifier` class, but user code can substitute their own. + * `AssetField` formfield to provide an `UploadField` style uploader for the new `DBFile` database field. ## Deprecated classes/methods diff --git a/filesystem/Upload.php b/filesystem/Upload.php index 50c6ef0f3..76e574c4b 100644 --- a/filesystem/Upload.php +++ b/filesystem/Upload.php @@ -124,16 +124,92 @@ class Upload extends Controller { return Injector::inst()->createWithArgs('AssetNameGenerator', array($filename)); } + /** + * + * @return AssetStore + */ + protected function getAssetStore() { + return Injector::inst()->get('AssetStore'); + } + + /** + * Save an file passed from a form post into the AssetStore directly + * + * @param $tmpFile array Indexed array that PHP generated for every file it uploads. + * @param $folderPath string Folder path relative to /assets + * @return array|false Either the tuple array, or false if the file could not be saved + */ + public function load($tmpFile, $folderPath = false) { + // Validate filename + $filename = $this->getValidFilename($tmpFile, $folderPath); + if(!$filename) { + return false; + } + + // Save file into backend + $result = $this->storeTempFile($tmpFile, $filename, $this->getAssetStore()); + + //to allow extensions to e.g. create a version after an upload + $this->extend('onAfterLoad', $result); + return $result; + } + /** * Save an file passed from a form post into this object. * File names are filtered through {@link FileNameFilter}, see class documentation * on how to influence this behaviour. * - * @param $tmpFile array Indexed array that PHP generated for every file it uploads. - * @param $folderPath string Folder path relative to /assets - * @return Boolean|string Either success or error-message. + * @param array $tmpFile + * @param AssetContainer $file + * @return bool True if the file was successfully saved into this record */ - public function load($tmpFile, $folderPath = false) { + public function loadIntoFile($tmpFile, $file = null, $folderPath = false) { + $this->file = $file; + + // Validate filename + $filename = $this->getValidFilename($tmpFile, $folderPath); + if(!$filename) { + return false; + } + $filename = $this->resolveExistingFile($filename); + + // Save changes to underlying record (if it's a DataObject) + $this->storeTempFile($tmpFile, $filename, $this->file); + if($this->file instanceof DataObject) { + $this->file->write(); + } + + //to allow extensions to e.g. create a version after an upload + $this->file->extend('onAfterUpload'); + $this->extend('onAfterLoadIntoFile', $this->file); + return true; + } + + /** + * Assign this temporary file into the given destination + * + * @param array $tmpFile + * @param string $filename + * @param AssetContainer|AssetStore $container + * @return array + */ + protected function storeTempFile($tmpFile, $filename, $container) { + // Save file into backend + $conflictResolution = $this->replaceFile + ? AssetStore::CONFLICT_OVERWRITE + : AssetStore::CONFLICT_RENAME; + return $container->setFromLocalFile($tmpFile['tmp_name'], $filename, null, null, $conflictResolution); + } + + /** + * Given a temporary file and upload path, validate the file and determine the + * value of the 'Filename' tuple that should be used to store this asset. + * + * @param array $tmpFile + * @param string $folderPath + * @return string|false Value of filename tuple, or false if invalid + */ + protected function getValidFilename($tmpFile, $folderPath = false) { if(!is_array($tmpFile)) { throw new InvalidArgumentException( "Upload::load() Not passed an array. Most likely, the form hasn't got the right enctype" @@ -157,23 +233,7 @@ class Upload extends Controller { if($folderPath) { $filename = File::join_paths($folderPath, $filename); } - - // Validate filename - $filename = $this->resolveExistingFile($filename); - - // Save file into backend - $conflictResolution = $this->replaceFile ? AssetStore::CONFLICT_OVERWRITE : AssetStore::CONFLICT_RENAME; - $this->file->setFromLocalFile($tmpFile['tmp_name'], $filename, null, null, $conflictResolution); - - // Save changes to underlying record (if it's a DataObject) - if($this->file instanceof DataObject) { - $this->file->write(); - } - - //to allow extensions to e.g. create a version after an upload - $this->file->extend('onAfterUpload'); - $this->extend('onAfterLoad', $this->file); - return true; + return $filename; } /** @@ -226,18 +286,6 @@ class Upload extends Controller { throw new Exception("Could not rename {$filename} with {$tries} tries"); } - /** - * Load temporary PHP-upload into File-object. - * - * @param array $tmpFile - * @param AssetContainer $file - * @return Boolean - */ - public function loadIntoFile($tmpFile, $file, $folderPath = false) { - $this->file = $file; - return $this->load($tmpFile, $folderPath); - } - /** * @return Boolean */ diff --git a/filesystem/storage/DBFile.php b/filesystem/storage/DBFile.php index dcded93a1..74d412bc9 100644 --- a/filesystem/storage/DBFile.php +++ b/filesystem/storage/DBFile.php @@ -87,13 +87,12 @@ class DBFile extends CompositeDBField implements AssetContainer, ShortcodeHandle 'Title' => 'Varchar', 'MimeType' => 'Varchar', 'String' => 'Text', - 'Tag' => 'HTMLText' + 'Tag' => 'HTMLText', + 'Size' => 'Varchar' ); public function scaffoldFormField($title = null, $params = null) { - return null; - // @todo - //return new AssetUploadField($this->getName(), $title); + return AssetField::create($this->getName(), $title); } /** @@ -241,13 +240,11 @@ class DBFile extends CompositeDBField implements AssetContainer, ShortcodeHandle /** * Get URL, but without resampling. + * Note that this will return the url even if the file does not exist. * * @return string */ public function getSourceURL() { - if(!$this->exists()) { - return null; - } return $this ->getStore() ->getAsURL($this->Filename, $this->Hash, $this->Variant); @@ -294,7 +291,12 @@ class DBFile extends CompositeDBField implements AssetContainer, ShortcodeHandle } public function exists() { - return !empty($this->Filename); + if(empty($this->Filename)) { + return false; + } + return $this + ->getStore() + ->exists($this->Filename, $this->Hash, $this->Variant); } public static function get_shortcodes() { @@ -454,4 +456,18 @@ class DBFile extends CompositeDBField implements AssetContainer, ShortcodeHandle return parent::setField($field, $value, $markChanged); } + + + /** + * Returns the size of the file type in an appropriate format. + * + * @return string|false String value, or false if doesn't exist + */ + public function getSize() { + $size = $this->getAbsoluteSize(); + if($size) { + return \File::format_size($size); + } + return false; + } } diff --git a/forms/AssetField.php b/forms/AssetField.php new file mode 100644 index 000000000..e344811c5 --- /dev/null +++ b/forms/AssetField.php @@ -0,0 +1,766 @@ + '$Action', + ); + + private static $casting = array( + 'Value' => 'DBFile', + 'UploadFieldThumbnailURL' => 'Varchar' + ); + + /** + * Template to use for the file button widget + * + * @var string + */ + protected $templateFileButtons = 'AssetField_FileButtons'; + + /** + * Parent data record. Will be infered from parent form or controller if blank. The destination + * DBFile should be a property of the name $name on this object. + * + * @var DataObject + */ + protected $record; + + /** + * Config for this field used in the front-end javascript + * (will be merged into the config of the javascript file upload plugin). + * + * @var array + */ + protected $ufConfig = array(); + + /** + * Front end config defaults + * + * @config + * @var array + */ + private static $defaultConfig = array( + /** + * Automatically upload the file once selected + * + * @var boolean + */ + 'autoUpload' => true, + + /** + * Can the user upload new files. + * String values are interpreted as permission codes. + * + * @var boolean|string + */ + 'canUpload' => true, + + /** + * Shows the target folder for new uploads in the field UI. + * Disable to keep the internal filesystem structure hidden from users. + * + * @var boolean|string + */ + 'canPreviewFolder' => true, + + /** + * Indicate a change event to the containing form if an upload + * or file edit/delete was performed. + * + * @var boolean + */ + 'changeDetection' => true, + + /** + * Maximum width of the preview thumbnail + * + * @var integer + */ + 'previewMaxWidth' => 80, + + /** + * Maximum height of the preview thumbnail + * + * @var integer + */ + 'previewMaxHeight' => 60, + + /** + * javascript template used to display uploading files + * + * @see javascript/UploadField_uploadtemplate.js + * @var string + */ + 'uploadTemplateName' => 'ss-uploadfield-uploadtemplate', + + /** + * javascript template used to display already uploaded files + * + * @see javascript/UploadField_downloadtemplate.js + * @var string + */ + 'downloadTemplateName' => 'ss-uploadfield-downloadtemplate' + ); + + /** + * Folder to display in "Select files" list. + * Defaults to listing all files regardless of folder. + * The folder path should be relative to the webroot. + * See {@link FileField->folderName} to set the upload target instead. + * + * @var string + * @example admin/folder/subfolder + */ + protected $displayFolderName; + + /** + * Construct a new UploadField instance + * + * @param string $name The internal field name, passed to forms. + * @param string $title The field label. + * @param Form $form Reference to the container form + */ + public function __construct($name, $title = null) { + $this->addExtraClass('ss-upload'); // class, used by js + $this->addExtraClass('ss-uploadfield'); // class, used by css for uploadfield only + + $this->ufConfig = array_merge($this->ufConfig, self::config()->defaultConfig); + + parent::__construct($name, $title); + + // AssetField always uses rename replacement method + $this->getUpload()->setReplaceFile(false); + + // filter out '' since this would be a regex problem on JS end + $this->getValidator()->setAllowedExtensions( + array_filter(Config::inst()->get('File', 'allowed_extensions')) + ); + + // get the lower max size + $maxUpload = File::ini2bytes(ini_get('upload_max_filesize')); + $maxPost = File::ini2bytes(ini_get('post_max_size')); + $this->getValidator()->setAllowedMaxFileSize(min($maxUpload, $maxPost)); + } + + /** + * Set name of template used for Buttons on each file (replace, edit, remove, delete) (without path or extension) + * + * @param string + * @return $this + */ + public function setTemplateFileButtons($template) { + $this->templateFileButtons = $template; + return $this; + } + + /** + * @return string + */ + public function getTemplateFileButtons() { + return $this->templateFileButtons; + } + + /** + * Determine if the target folder for new uploads in is visible the field UI. + * + * @return boolean + */ + public function canPreviewFolder() { + if(!$this->isActive()) { + return false; + } + $can = $this->getConfig('canPreviewFolder'); + if(is_bool($can)) { + return $can; + } + return Permission::check($can); + } + + /** + * Determine if the target folder for new uploads in is visible the field UI. + * Disable to keep the internal filesystem structure hidden from users. + * + * @param boolean|string $canPreviewFolder Either a boolean flag, or a + * required permission code + * @return $this Self reference + */ + public function setCanPreviewFolder($canPreviewFolder) { + return $this->setConfig('canPreviewFolder', $canPreviewFolder); + } + + /** + * @param string + * @return $this + */ + public function setDisplayFolderName($name) { + $this->displayFolderName = $name; + return $this; + } + + /** + * @return string + */ + public function getDisplayFolderName() { + return $this->displayFolderName; + } + + /** + * Force a record to be used as "Parent" for uploaded Files (eg a Page with a has_one to File) + * @param DataObject $record + */ + public function setRecord($record) { + $this->record = $record; + return $this; + } + + /** + * Get the record to use as "Parent" for uploaded Files (eg a Page with a has_one to File) If none is set, it will + * use Form->getRecord(). + * + * @return DataObject + */ + public function getRecord() { + if (!$this->record + && $this->form + && ($record = $this->form->getRecord()) + && $record instanceof DataObject + ) { + $this->record = $record; + } + return $this->record; + } + + public function setValue($value, $record = null) { + // Extract value from underlying record + if(empty($value) && $this->getName() && $record instanceof DataObject) { + $name = $this->getName(); + $value = $record->$name; + } + + // Convert asset container to tuple value + if($value instanceof AssetContainer) { + if($value->exists()) { + $value = array( + 'Filename' => $value->getFilename(), + 'Hash' => $value->getHash(), + 'Variant' => $value->getVariant() + ); + } else { + $value = null; + } + } + + // If javascript is disabled, direct file upload (non-html5 style) can + // trigger a single or multiple file submission. Note that this may be + // included in addition to re-submitted File IDs as above, so these + // should be added to the list instead of operated on independently. + if($uploadedFile = $this->extractUploadedFileData($value)) { + $value = $this->saveTemporaryFile($uploadedFile, $error); + if(!$value) { + throw new ValidationException($error); + } + } + + // Set value using parent + return parent::setValue($value, $record); + } + + public function Value() { + // Re-override FileField Value to use data value + return $this->dataValue(); + } + + public function saveInto(DataObjectInterface $record) { + // Check required relation details are available + $name = $this->getName(); + if(!$name) { + return $this; + } + $value = $this->Value(); + foreach(array('Filename', 'Hash', 'Variant') as $part) { + $partValue = isset($value[$part]) + ? $value[$part] + : null; + $record->setField("{$name}{$part}", $partValue); + } + return $this; + } + + /** + * Assign a front-end config variable for the upload field + * + * @see https://github.com/blueimp/jQuery-File-Upload/wiki/Options for the list of front end options available + * + * @param string $key + * @param mixed $val + * @return $this self reference + */ + public function setConfig($key, $val) { + $this->ufConfig[$key] = $val; + return $this; + } + + /** + * Gets a front-end config variable for the upload field + * + * @see https://github.com/blueimp/jQuery-File-Upload/wiki/Options for the list of front end options available + * + * @param string $key + * @return mixed + */ + public function getConfig($key) { + if(isset($this->ufConfig[$key])) { + return $this->ufConfig[$key]; + } + } + + /** + * Determine if the field should automatically upload the file. + * + * @return boolean + */ + public function getAutoUpload() { + return $this->getConfig('autoUpload'); + } + + /** + * Determine if the field should automatically upload the file + * + * @param boolean $autoUpload + * @return $this Self reference + */ + public function setAutoUpload($autoUpload) { + return $this->setConfig('autoUpload', $autoUpload); + } + + /** + * Determine if the user has permission to upload. + * + * @return boolean + */ + public function canUpload() { + if(!$this->isActive()) { + return false; + } + $can = $this->getConfig('canUpload'); + if(is_bool($can)) { + return $can; + } + return Permission::check($can); + } + + /** + * Specify whether the user can upload files. + * String values will be treated as required permission codes + * + * @param bool|string $canUpload Either a boolean flag, or a required + * permission code + * @return $this Self reference + */ + public function setCanUpload($canUpload) { + return $this->setConfig('canUpload', $canUpload); + } + + /** + * Returns true if the field is neither readonly nor disabled + * + * @return bool + */ + public function isActive() { + return !$this->isDisabled() && !$this->isReadonly(); + } + + /** + * Gets thumbnail width. Defaults to 80 + * + * @return int + */ + public function getPreviewMaxWidth() { + return $this->getConfig('previewMaxWidth'); + } + + /** + * Set thumbnail width. + * + * @param int $previewMaxWidth + * @return $this Self reference + */ + public function setPreviewMaxWidth($previewMaxWidth) { + return $this->setConfig('previewMaxWidth', $previewMaxWidth); + } + + /** + * Gets thumbnail height. Defaults to 60 + * + * @return int + */ + public function getPreviewMaxHeight() { + return $this->getConfig('previewMaxHeight'); + } + + /** + * Set thumbnail height. + * + * @param int $previewMaxHeight + * @return $this Self reference + */ + public function setPreviewMaxHeight($previewMaxHeight) { + return $this->setConfig('previewMaxHeight', $previewMaxHeight); + } + + /** + * javascript template used to display uploading files + * Defaults to 'ss-uploadfield-uploadtemplate' + * + * @see javascript/UploadField_uploadtemplate.js + * @var string + */ + public function getUploadTemplateName() { + return $this->getConfig('uploadTemplateName'); + } + + /** + * Set javascript template used to display uploading files + * + * @param string $uploadTemplateName + * @return $this Self reference + */ + public function setUploadTemplateName($uploadTemplateName) { + return $this->setConfig('uploadTemplateName', $uploadTemplateName); + } + + /** + * javascript template used to display already uploaded files + * Defaults to 'ss-downloadfield-downloadtemplate' + * + * @see javascript/DownloadField_downloadtemplate.js + * @var string + */ + public function getDownloadTemplateName() { + return $this->getConfig('downloadTemplateName'); + } + + /** + * Set javascript template used to display already uploaded files + * + * @param string $downloadTemplateName + * @return $this Self reference + */ + public function setDownloadTemplateName($downloadTemplateName) { + return $this->setConfig('downloadTemplateName', $downloadTemplateName); + } + + public function Field($properties = array()) { + Requirements::javascript(THIRDPARTY_DIR . '/jquery/jquery.js'); + Requirements::javascript(THIRDPARTY_DIR . '/jquery-ui/jquery-ui.js'); + Requirements::javascript(THIRDPARTY_DIR . '/jquery-entwine/dist/jquery.entwine-dist.js'); + Requirements::javascript(FRAMEWORK_ADMIN_DIR . '/javascript/ssui.core.js'); + Requirements::add_i18n_javascript(FRAMEWORK_DIR . '/javascript/lang'); + + Requirements::combine_files('uploadfield.js', array( + // @todo jquery templates is a project no longer maintained and should be retired at some point. + THIRDPARTY_DIR . '/javascript-templates/tmpl.js', + THIRDPARTY_DIR . '/javascript-loadimage/load-image.js', + THIRDPARTY_DIR . '/jquery-fileupload/jquery.iframe-transport.js', + THIRDPARTY_DIR . '/jquery-fileupload/cors/jquery.xdr-transport.js', + THIRDPARTY_DIR . '/jquery-fileupload/jquery.fileupload.js', + THIRDPARTY_DIR . '/jquery-fileupload/jquery.fileupload-ui.js', + FRAMEWORK_DIR . '/javascript/UploadField_uploadtemplate.js', + FRAMEWORK_DIR . '/javascript/UploadField_downloadtemplate.js', + FRAMEWORK_DIR . '/javascript/UploadField.js', + )); + Requirements::css(THIRDPARTY_DIR . '/jquery-ui-themes/smoothness/jquery-ui.css'); // TODO hmmm, remove it? + Requirements::css(FRAMEWORK_DIR . '/css/UploadField.css'); + + // Calculated config as per jquery.fileupload-ui.js + $config = array( + 'allowedMaxFileNumber' => 1, // Only one file allowed for AssetField + 'url' => $this->Link('upload'), + 'urlSelectDialog' => $this->Link('select'), + 'urlAttach' => $this->Link('attach'), + 'urlFileExists' => $this->link('fileexists'), + 'acceptFileTypes' => '.+$', + // Fileupload treats maxNumberOfFiles as the max number of _additional_ items allowed + 'maxNumberOfFiles' => $this->Value() ? 0 : 1, + 'replaceFile' => false, // Should always be false for AssetField + ); + + // Validation: File extensions + if ($allowedExtensions = $this->getAllowedExtensions()) { + $config['acceptFileTypes'] = '(\.|\/)(' . implode('|', $allowedExtensions) . ')$'; + $config['errorMessages']['acceptFileTypes'] = _t( + 'File.INVALIDEXTENSIONSHORT', + 'Extension is not allowed' + ); + } + + // Validation: File size + if ($allowedMaxFileSize = $this->getValidator()->getAllowedMaxFileSize()) { + $config['maxFileSize'] = $allowedMaxFileSize; + $config['errorMessages']['maxFileSize'] = _t( + 'File.TOOLARGESHORT', + 'Filesize exceeds {size}', + array('size' => File::format_size($config['maxFileSize'])) + ); + } + + $mergedConfig = array_merge($config, $this->ufConfig); + return $this->customise(array( + 'ConfigString' => Convert::raw2json($mergedConfig), + 'UploadFieldFileButtons' => $this->renderWith($this->getTemplateFileButtons()) + ))->renderWith($this->getTemplates()); + } + + /** + * Validation method for this field, called when the entire form is validated + * + * @param Validator $validator + * @return boolean + */ + public function validate($validator) { + $name = $this->getName(); + $value = $this->Value(); + + // If there is no file then quit + if(!$value) { + return true; + } + + // Revalidate each file against nested validator + $this->getUpload()->clearErrors(); + + // Generate $_FILES style file attribute array for upload validator + $store = $this->getAssetStore(); + $mime = $store->getMimeType($value['Filename'], $value['Hash'], $value['Variant']); + $metadata = $store->getMetadata($value['Filename'], $value['Hash'], $value['Variant']); + $tmpFile = array( + 'name' => $value['Filename'], + 'type' => $mime, + 'size' => isset($metadata['size']) ? $metadata['size'] : 0, + 'tmp_name' => null, // Should bypass is_uploaded_file check + 'error' => UPLOAD_ERR_OK, + ); + $this->getUpload()->validate($tmpFile); + + // Check all errors + if($errors = $this->getUpload()->getErrors()) { + foreach($errors as $error) { + $validator->validationError($name, $error, "validation"); + } + return false; + } + + return true; + } + + /** + * Given an array of post variables, extract all temporary file data into an array + * + * @param array $postVars Array of posted form data + * @return array data for uploaded file + */ + protected function extractUploadedFileData($postVars) { + // Note: Format of posted file parameters in php is a feature of using + // for multiple file uploads + + // Skip empty file + if(empty($postVars['tmp_name'])) { + return null; + } + + // Return single level array for posted file + if(empty($postVars['tmp_name']['Upload'])) { + return $postVars; + } + + // Extract posted feedback value + $tmpFile = array(); + foreach(array('name', 'type', 'tmp_name', 'error', 'size') as $field) { + $tmpFile[$field] = $postVars[$field]['Upload']; + } + return $tmpFile; + } + + /** + * Loads the temporary file data into the asset store, and return the tuple details + * for the result. + * + * @param array $tmpFile Temporary file data + * @param string $error Error message + * @return array Result of saved file, or null if error + */ + protected function saveTemporaryFile($tmpFile, &$error = null) { + $error = null; + if (empty($tmpFile)) { + $error = _t('UploadField.FIELDNOTSET', 'File information not found'); + return null; + } + + if($tmpFile['error']) { + $error = $tmpFile['error']; + return null; + } + + // Get the uploaded file into a new file object. + try { + $result = $this + ->getUpload() + ->load($tmpFile, $this->getFolderName()); + } catch (Exception $e) { + // we shouldn't get an error here, but just in case + $error = $e->getMessage(); + return null; + } + + // Check if upload field has an error + if ($this->getUpload()->isError()) { + $error = implode(' ' . PHP_EOL, $this->getUpload()->getErrors()); + return null; + } + + // return tuple array of Filename, Hash and Variant + return $result; + } + + /** + * Safely encodes the File object with all standard fields required + * by the front end + * + * @param string $filename + * @param string $hash + * @param string $variant + * @return array Encoded list of file attributes + */ + protected function encodeAssetAttributes($filename, $hash, $variant) { + // Force regeneration of file thumbnail for this tuple (without saving into db) + $object = DBFile::create(); + $object->setValue(array('Filename' => $filename, 'Hash' => $hash, 'Variant' => $variant)); + + return array( + 'filename' => $filename, + 'hash' => $hash, + 'variant' => $variant, + 'name' => $object->getBasename(), + 'url' => $object->getURL(), + 'thumbnail_url' => $object->ThumbnailURL( + $this->getPreviewMaxWidth(), + $this->getPreviewMaxHeight() + ), + 'size' => $object->getAbsoluteSize(), + 'type' => File::get_file_type($object->getFilename()), + 'buttons' => (string)$this->renderWith($this->getTemplateFileButtons()), + 'fieldname' => $this->getName() + ); + } + + /** + * Action to handle upload of a single file + * + * @param SS_HTTPRequest $request + * @return SS_HTTPResponse + */ + public function upload(SS_HTTPRequest $request) { + if($this->isDisabled() || $this->isReadonly() || !$this->canUpload()) { + return $this->httpError(403); + } + + // Protect against CSRF on destructive action + $token = $this + ->getForm() + ->getSecurityToken(); + if(!$token->checkRequest($request)) { + return $this->httpError(400); + } + + // Get form details + $name = $this->getName(); + $postVars = $request->postVar($name); + + // Extract uploaded files from Form data + $uploadedFile = $this->extractUploadedFileData($postVars); + if(!$uploadedFile) { + return $this->httpError(400); + } + + // Save the temporary files into a File objects + // and save data/error on a per file basis + $result = $this->saveTemporaryFile($uploadedFile, $error); + if(empty($result)) { + $return = array('error' => $error); + } else { + $return = $this->encodeAssetAttributes($result['Filename'], $result['Hash'], $result['Variant']); + } + $this + ->getUpload() + ->clearErrors(); + + // Format response with json + $response = new SS_HTTPResponse(Convert::raw2json(array($return))); + $response->addHeader('Content-Type', 'text/plain'); + return $response; + } + + public function performReadonlyTransformation() { + $clone = clone $this; + $clone->addExtraClass('readonly'); + $clone->setReadonly(true); + return $clone; + } + + /** + * Gets the foreign class that needs to be created, or 'File' as default if there + * is no relationship, or it cannot be determined. + * + * @param $default Default value to return if no value could be calculated + * @return string Foreign class name. + */ + public function getRelationAutosetClass($default = 'File') { + + // Don't autodetermine relation if no relationship between parent record + if(!$this->relationAutoSetting) return $default; + + // Check record and name + $name = $this->getName(); + $record = $this->getRecord(); + if(empty($name) || empty($record)) { + return $default; + } else { + $class = $record->getRelationClass($name); + return empty($class) ? $default : $class; + } + } + + /** + * @return AssetStore + */ + protected function getAssetStore() { + return Injector::inst()->get('AssetStore'); + } + +} diff --git a/forms/FormField.php b/forms/FormField.php index 69b05a5ec..335c22f06 100644 --- a/forms/FormField.php +++ b/forms/FormField.php @@ -460,7 +460,15 @@ class FormField extends RequestHandler { // // CSS class needs to be different from the one rendered through {@link FieldHolder()}. if($this->Message()) { - $classes[] .= 'holder-' . $this->MessageType(); + $classes[] = 'holder-' . $this->MessageType(); + } + + if($this->isDisabled()) { + $classes[] = 'disabled'; + } + + if($this->isReadonly()) { + $classes[] = 'readonly'; } return implode(' ', $classes); diff --git a/forms/UploadField.php b/forms/UploadField.php index 18f8b4026..56e1a96bb 100644 --- a/forms/UploadField.php +++ b/forms/UploadField.php @@ -80,11 +80,18 @@ class UploadField extends FileField { /** * Config for this field used in the front-end javascript * (will be merged into the config of the javascript file upload plugin). - * See framework/_config/uploadfield.yml for configuration defaults and documentation. * * @var array */ - protected $ufConfig = array( + protected $ufConfig = array(); + + /** + * Front end config defaults + * + * @config + * @var array + */ + private static $defaultConfig = array( /** * Automatically upload the file once selected * @@ -212,7 +219,7 @@ class UploadField extends FileField { $this->addExtraClass('ss-upload'); // class, used by js $this->addExtraClass('ss-uploadfield'); // class, used by css for uploadfield only - $this->ufConfig = array_merge($this->ufConfig, self::config()->defaultConfig); + $this->ufConfig = self::config()->defaultConfig; parent::__construct($name, $title); @@ -905,17 +912,6 @@ class UploadField extends FileField { ); } - public function extraClass() { - if($this->isDisabled()) { - $this->addExtraClass('disabled'); - } - if($this->isReadonly()) { - $this->addExtraClass('readonly'); - } - - return parent::extraClass(); - } - public function Field($properties = array()) { Requirements::javascript(THIRDPARTY_DIR . '/jquery/jquery.js'); Requirements::javascript(THIRDPARTY_DIR . '/jquery-ui/jquery-ui.js'); diff --git a/javascript/UploadField_downloadtemplate.js b/javascript/UploadField_downloadtemplate.js index 43340b201..006edd1ef 100644 --- a/javascript/UploadField_downloadtemplate.js +++ b/javascript/UploadField_downloadtemplate.js @@ -7,9 +7,14 @@ window.tmpl.cache['ss-uploadfield-downloadtemplate'] = tmpl( '' + '{% } %}' + '
' + - '{% if (!file.error) { %}' + + '{% if (!file.error && file.id) { %}' + '' + '{% } %}' + + '{% if (!file.error && file.filename) { %}' + + '' + + '' + + '' + + '{% } %}' + '