Features (some might not be available to old browsers): * * - File Drag&Drop support * - Progressbar * - Image thumbnail/file icons even before upload finished * - Saving into relations on form submit * - Edit file * - allowedExtensions is by default File::$allowed_extensions
  • maxFileSize the value of min(upload_max_filesize, * post_max_size) from php.ini * * <>Usage * * @example * $UploadField = new UploadField('AttachedImages', 'Please upload some images (max. 5 files)'); * $UploadField->setAllowedFileCategories('image'); * $UploadField->setAllowedMaxFileNumber(5); * * * @author Zauberfisch * @package forms * @subpackages fields-files */ class UploadField extends FileField { /** * @var array */ private static $allowed_actions = array( 'upload', 'attach', 'handleItem', 'handleSelect', 'fileexists' ); /** * @var array */ private static $url_handlers = array( 'item/$ID' => 'handleItem', 'select' => 'handleSelect', '$Action!' => '$Action', ); /** * Template to use for the file button widget * * @var string */ protected $templateFileButtons = 'UploadField_FileButtons'; /** * Template to use for the edit form * * @var string */ protected $templateFileEdit = 'UploadField_FileEdit'; /** * Parent data record. Will be infered from parent form or controller if blank. * * @var DataObject */ protected $record; /** * Items loaded into this field. May be a RelationList, or any other SS_List * * @var SS_List */ protected $items; /** * 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, /** * Restriction on number of files that may be set for this field. Set to null to allow * unlimited. If record has a has_one and allowedMaxFileNumber is null, it will be set to 1. * The resulting value will be set to maxNumberOfFiles * * @var integer */ 'allowedMaxFileNumber' => null, /** * Can the user upload new files, or just select from existing files. * String values are interpreted as permission codes. * * @var boolean|string */ 'canUpload' => true, /** * Can the user attach files from the assets archive on the site? * String values are interpreted as permission codes. * * @var boolean|string */ 'canAttachExisting' => "CMS_ACCESS_AssetAdmin", /** * 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', /** * Show a warning when overwriting a file. * This requires Upload->replaceFile config to be set to true, otherwise * files will be renamed instead of overwritten * * @see Upload * @var boolean */ 'overwriteWarning' => true ); /** * @var String 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. * @example admin/folder/subfolder */ protected $displayFolderName; /** * FieldList $fields or string $name (of a method on File to provide a fields) for the EditForm * @example 'getCMSFields' * * @var FieldList|string */ protected $fileEditFields = null; /** * FieldList $actions or string $name (of a method on File to provide a actions) for the EditForm * @example 'getCMSActions' * * @var FieldList|string */ protected $fileEditActions = null; /** * Validator (eg RequiredFields) or string $name (of a method on File to provide a Validator) for the EditForm * @example 'getCMSValidator' * * @var RequiredFields|string */ protected $fileEditValidator = null; /** * Construct a new UploadField instance * * @param string $name The internal field name, passed to forms. * @param string $title The field label. * @param SS_List $items If no items are defined, the field will try to auto-detect an existing relation on * @link $record}, with the same name as the field name. * @param Form $form Reference to the container form */ public function __construct($name, $title = null, SS_List $items = null) { // TODO thats the first thing that came to my head, feel free to change it $this->addExtraClass('ss-upload'); // class, used by js $this->addExtraClass('ss-uploadfield'); // class, used by css for uploadfield only $this->ufConfig = self::config()->defaultConfig; parent::__construct($name, $title); if($items) $this->setItems($items); // 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 */ public function setTemplateFileButtons($template) { $this->templateFileButtons = $template; return $this; } /** * @return string */ public function getTemplateFileButtons() { return $this->templateFileButtons; } /** * Set name of template used for the edit (inline & popup) of a file file (without path or extension) * * @param string */ public function setTemplateFileEdit($template) { $this->templateFileEdit = $template; return $this; } /** * @return string */ public function getTemplateFileEdit() { return $this->templateFileEdit; } /** * 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'); return (is_bool($can)) ? $can : 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 UploadField Self reference */ public function setCanPreviewFolder($canPreviewFolder) { return $this->setConfig('canPreviewFolder', $canPreviewFolder); } /** * Determine if the field should show a warning when overwriting a file. * This requires Upload->replaceFile config to be set to true, otherwise * files will be renamed instead of overwritten (although the warning will * still be displayed) * * @return boolean */ public function getOverwriteWarning() { return $this->getConfig('overwriteWarning'); } /** * Determine if the field should show a warning when overwriting a file. * This requires Upload->replaceFile config to be set to true, otherwise * files will be renamed instead of overwritten (although the warning will * still be displayed) * * @param boolean $overwriteWarning * @return UploadField Self reference */ public function setOverwriteWarning($overwriteWarning) { return $this->setConfig('overwriteWarning', $overwriteWarning); } /** * @param String */ 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() or Form->Controller()->data() * * @return DataObject */ public function getRecord() { if (!$this->record && $this->form) { if (($record = $this->form->getRecord()) && ($record instanceof DataObject)) { $this->record = $record; } elseif (($controller = $this->form->Controller()) && $controller->hasMethod('data') && ($record = $controller->data()) && ($record instanceof DataObject) ) { $this->record = $record; } } return $this->record; } /** * Loads the related record values into this field. UploadField can be uploaded * in one of three ways: * * - By passing in a list of file IDs in the $value parameter (an array with a single * key 'Files', with the value being the actual array of IDs). * - By passing in an explicit list of File objects in the $record parameter, and * leaving $value blank. * - By passing in a dataobject in the $record parameter, from which file objects * will be extracting using the field name as the relation field. * * Each of these methods will update both the items (list of File objects) and the * field value (list of file ID values). * * @param array $value Array of submitted form data, if submitting from a form * @param array|DataObject|SS_List $record Full source record, either as a DataObject, * SS_List of items, or an array of submitted form data * @return $this Self reference */ public function setValue($value, $record = null) { // If we're not passed a value directly, we can attempt to infer the field // value from the second parameter by inspecting its relations $items = new ArrayList(); // Determine format of presented data if(empty($value) && $record) { // If a record is given as a second parameter, but no submitted values, // then we should inspect this instead for the form values if(($record instanceof DataObject) && $record->hasMethod($this->getName())) { // If given a dataobject use reflection to extract details $data = $record->{$this->getName()}(); if($data instanceof DataObject) { // If has_one, add sole item to default list $items->push($data); } elseif($data instanceof SS_List) { // For many_many and has_many relations we can use the relation list directly $items = $data; } } elseif($record instanceof SS_List) { // If directly passing a list then save the items directly $items = $record; } } elseif(!empty($value['Files'])) { // If value is given as an array (such as a posted form), extract File IDs from this $class = $this->getRelationAutosetClass(); $items = DataObject::get($class)->byIDs($value['Files']); } // 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($uploadedFiles = $this->extractUploadedFileData($value)) { foreach($uploadedFiles as $tempFile) { $file = $this->saveTemporaryFile($tempFile, $error); if($file) { $items->add($file); } else { throw new ValidationException($error); } } } // Filter items by what's allowed to be viewed $filteredItems = new ArrayList(); $fileIDs = array(); foreach($items as $file) { if($file->exists() && $file->canView()) { $filteredItems->push($file); $fileIDs[] = $file->ID; } } // Filter and cache updated item list $this->items = $filteredItems; // Same format as posted form values for this field. Also ensures that // $this->setValue($this->getValue()); is non-destructive $value = $fileIDs ? array('Files' => $fileIDs) : null; // Set value using parent return parent::setValue($value, $record); } /** * Sets the items assigned to this field as an SS_List of File objects. * Calling setItems will also update the value of this field, as well as * updating the internal list of File items. * * @param SS_List $items * @return UploadField self reference */ public function setItems(SS_List $items) { return $this->setValue(null, $items); } /** * Retrieves the current list of files * * @return SS_List */ public function getItems() { return $this->items ? $this->items : new ArrayList(); } /** * Retrieves a customised list of all File records to ensure they are * properly viewable when rendered in the field template. * * @return SS_List[ViewableData_Customised] */ public function getCustomisedItems() { $customised = new ArrayList(); foreach($this->getItems() as $file) { $customised->push($this->customiseFile($file)); } return $customised; } /** * Retrieves the list of selected file IDs * * @return array */ public function getItemIDs() { $value = $this->Value(); return empty($value['Files']) ? array() : $value['Files']; } 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 $fieldname = $this->getName(); if(!$fieldname) return $this; // Get details to save $idList = $this->getItemIDs(); // Check type of relation $relation = $record->hasMethod($fieldname) ? $record->$fieldname() : null; if($relation && ($relation instanceof RelationList || $relation instanceof UnsavedRelationList)) { // has_many or many_many $relation->setByIDList($idList); } elseif($record->hasOneComponent($fieldname)) { // has_one $record->{"{$fieldname}ID"} = $idList ? reset($idList) : 0; } return $this; } /** * Customises a file with additional details suitable for rendering in the * UploadField.ss template * * @param AssetContainer $file * @return ViewableData_Customised */ protected function customiseFile(AssetContainer $file) { $file = $file->customise(array( 'UploadFieldThumbnailURL' => $this->getThumbnailURLForFile($file), 'UploadFieldDeleteLink' => $this->getItemHandler($file->ID)->DeleteLink(), 'UploadFieldEditLink' => $this->getItemHandler($file->ID)->EditLink(), 'UploadField' => $this )); // we do this in a second customise to have the access to the previous customisations return $file->customise(array( 'UploadFieldFileButtons' => (string)$file->renderWith($this->getTemplateFileButtons()) )); } /** * 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 UploadField 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 null; 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 UploadField Self reference */ public function setAutoUpload($autoUpload) { return $this->setConfig('autoUpload', $autoUpload); } /** * Determine maximum number of files allowed to be attached * Defaults to 1 for has_one and null (unlimited) for * many_many and has_many relations. * * @return integer|null Maximum limit, or null for no limit */ public function getAllowedMaxFileNumber() { $allowedMaxFileNumber = $this->getConfig('allowedMaxFileNumber'); // if there is a has_one relation with that name on the record and // allowedMaxFileNumber has not been set, it's wanted to be 1 if(empty($allowedMaxFileNumber)) { $record = $this->getRecord(); $name = $this->getName(); if($record && $record->hasOneComponent($name)) { return 1; // Default for has_one } else { return null; // Default for has_many and many_many } } else { return $allowedMaxFileNumber; } } /** * Determine maximum number of files allowed to be attached. * * @param integer|null $allowedMaxFileNumber Maximum limit. 0 or null will be treated as unlimited * @return UploadField Self reference */ public function setAllowedMaxFileNumber($allowedMaxFileNumber) { return $this->setConfig('allowedMaxFileNumber', $allowedMaxFileNumber); } /** * Determine if the user has permission to upload. * * @return boolean */ public function canUpload() { if(!$this->isActive()) return false; $can = $this->getConfig('canUpload'); return (is_bool($can)) ? $can : Permission::check($can); } /** * Specify whether the user can upload files. * String values will be treated as required permission codes * * @param boolean|string $canUpload Either a boolean flag, or a required * permission code * @return UploadField Self reference */ public function setCanUpload($canUpload) { return $this->setConfig('canUpload', $canUpload); } /** * Determine if the user has permission to attach existing files * By default returns true if the user has the CMS_ACCESS_AssetAdmin permission * * @return boolean */ public function canAttachExisting() { if(!$this->isActive()) return false; $can = $this->getConfig('canAttachExisting'); return (is_bool($can)) ? $can : Permission::check($can); } /** * Returns true if the field is neither readonly nor disabled * * @return boolean */ public function isActive() { return !$this->isDisabled() && !$this->isReadonly(); } /** * Specify whether the user can attach existing files * String values will be treated as required permission codes * * @param boolean|string $canAttachExisting Either a boolean flag, or a * required permission code * @return UploadField Self reference */ public function setCanAttachExisting($canAttachExisting) { return $this->setConfig('canAttachExisting', $canAttachExisting); } /** * Gets thumbnail width. Defaults to 80 * * @return integer */ public function getPreviewMaxWidth() { return $this->getConfig('previewMaxWidth'); } /** * @see UploadField::getPreviewMaxWidth() * * @param integer $previewMaxWidth * @return UploadField Self reference */ public function setPreviewMaxWidth($previewMaxWidth) { return $this->setConfig('previewMaxWidth', $previewMaxWidth); } /** * Gets thumbnail height. Defaults to 60 * * @return integer */ public function getPreviewMaxHeight() { return $this->getConfig('previewMaxHeight'); } /** * @see UploadField::getPreviewMaxHeight() * * @param integer $previewMaxHeight * @return UploadField 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'); } /** * @see UploadField::getUploadTemplateName() * * @param string $uploadTemplateName * @return UploadField 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'); } /** * @see Uploadfield::getDownloadTemplateName() * * @param string $downloadTemplateName * @return Uploadfield Self reference */ public function setDownloadTemplateName($downloadTemplateName) { return $this->setConfig('downloadTemplateName', $downloadTemplateName); } /** * FieldList $fields for the EditForm * @example 'getCMSFields' * * @param DataObject $file File context to generate fields for * @return FieldList List of form fields */ public function getFileEditFields(DataObject $file) { // Empty actions, generate default if(empty($this->fileEditFields)) { $fields = $file->getCMSFields(); // Only display main tab, to avoid overly complex interface if($fields->hasTabSet() && ($mainTab = $fields->findOrMakeTab('Root.Main'))) { $fields = $mainTab->Fields(); } return $fields; } // Fields instance if ($this->fileEditFields instanceof FieldList) { return $this->fileEditFields; } // Method to call on the given file if($file->hasMethod($this->fileEditFields)) { return $file->{$this->fileEditFields}(); } user_error("Invalid value for UploadField::fileEditFields", E_USER_ERROR); } /** * FieldList $fields or string $name (of a method on File to provide a fields) for the EditForm * @example 'getCMSFields' * * @param FieldList|string * @return Uploadfield Self reference */ public function setFileEditFields($fileEditFields) { $this->fileEditFields = $fileEditFields; return $this; } /** * FieldList $actions or string $name (of a method on File to provide a actions) for the EditForm * @example 'getCMSActions' * * @param DataObject $file File context to generate form actions for * @return FieldList Field list containing FormAction */ public function getFileEditActions(DataObject $file) { // Empty actions, generate default if(empty($this->fileEditActions)) { $actions = new FieldList($saveAction = new FormAction('doEdit', _t('UploadField.DOEDIT', 'Save'))); $saveAction->addExtraClass('ss-ui-action-constructive icon-accept'); return $actions; } // Actions instance if ($this->fileEditActions instanceof FieldList) { return $this->fileEditActions; } // Method to call on the given file if($file->hasMethod($this->fileEditActions)) { return $file->{$this->fileEditActions}(); } user_error("Invalid value for UploadField::fileEditActions", E_USER_ERROR); } /** * FieldList $actions or string $name (of a method on File to provide a actions) for the EditForm * @example 'getCMSActions' * * @param FieldList|string * @return Uploadfield Self reference */ public function setFileEditActions($fileEditActions) { $this->fileEditActions = $fileEditActions; return $this; } /** * Determines the validator to use for the edit form * @example 'getCMSValidator' * * @param DataObject $file File context to generate validator from * @return Validator Validator object */ public function getFileEditValidator(DataObject $file) { // Empty validator if(empty($this->fileEditValidator)) { return null; } // Validator instance if($this->fileEditValidator instanceof Validator) { return $this->fileEditValidator; } // Method to call on the given file if($file->hasMethod($this->fileEditValidator)) { return $file->{$this->fileEditValidator}(); } user_error("Invalid value for UploadField::fileEditValidator", E_USER_ERROR); } /** * Validator (eg RequiredFields) or string $name (of a method on File to provide a Validator) for the EditForm * @example 'getCMSValidator' * * @param Validator|string * @return Uploadfield Self reference */ public function setFileEditValidator($fileEditValidator) { $this->fileEditValidator = $fileEditValidator; return $this; } /** * * @param AssetContainer $file * @return string URL to thumbnail */ protected function getThumbnailURLForFile(AssetContainer $file) { if (!$file->exists()) { return null; } // Attempt to generate image at given size $width = $this->getPreviewMaxWidth(); $height = $this->getPreviewMaxHeight(); if ($file->hasMethod('ThumbnailURL')) { return $file->ThumbnailURL($width, $height); } if ($file->hasMethod('Thumbnail')) { return $file->Thumbnail($width, $height)->getURL(); } if ($file->hasMethod('Fit')) { return $file->Fit($width, $height)->getURL(); } // Check if unsized icon is available if($file->hasMethod('getIcon')) { return $file->getIcon(); } } public function getAttributes() { return array_merge( parent::getAttributes(), array('data-selectdialog-url', $this->Link('select')) ); } 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'); 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 $allowedMaxFileNumber = $this->getAllowedMaxFileNumber(); $config = array( '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' => $allowedMaxFileNumber ? ($allowedMaxFileNumber - count($this->getItemIDs())) : null, 'replaceFile' => $this->getUpload()->getReplaceFile(), ); // 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'])) ); } // Validation: Number of files if ($allowedMaxFileNumber) { if($allowedMaxFileNumber > 1) { $config['errorMessages']['maxNumberOfFiles'] = _t( 'UploadField.MAXNUMBEROFFILESSHORT', 'Can only upload {count} files', array('count' => $allowedMaxFileNumber) ); } else { $config['errorMessages']['maxNumberOfFiles'] = _t( 'UploadField.MAXNUMBEROFFILESONE', 'Can only upload one file' ); } } // add overwrite warning error message to the config object sent to Javascript if ($this->getOverwriteWarning()) { $config['errorMessages']['overwriteWarning'] = _t('UploadField.OVERWRITEWARNING', 'File with the same name already exists'); } $mergedConfig = array_merge($config, $this->ufConfig); return $this->customise(array( 'configString' => str_replace('"', """, Convert::raw2json($mergedConfig)), 'config' => new ArrayData($mergedConfig), 'multiple' => $allowedMaxFileNumber !== 1 ))->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(); $files = $this->getItems(); // If there are no files then quit if($files->count() == 0) return true; // Check max number of files $maxFiles = $this->getAllowedMaxFileNumber(); if($maxFiles && ($files->count() > $maxFiles)) { $validator->validationError( $name, _t( 'UploadField.MAXNUMBEROFFILES', 'Max number of {count} file(s) exceeded.', array('count' => $maxFiles) ), "validation" ); return false; } // Revalidate each file against nested validator $this->upload->clearErrors(); foreach($files as $file) { // Generate $_FILES style file attribute array for upload validator $tmpFile = array( 'name' => $file->Name, 'type' => null, // Not used for type validation 'size' => $file->AbsoluteSize, 'tmp_name' => null, // Should bypass is_uploaded_file check 'error' => UPLOAD_ERR_OK, ); $this->upload->validate($tmpFile); } // Check all errors if($errors = $this->upload->getErrors()) { foreach($errors as $error) { $validator->validationError($name, $error, "validation"); } return false; } return true; } /** * @param SS_HTTPRequest $request * @return UploadField_ItemHandler */ public function handleItem(SS_HTTPRequest $request) { return $this->getItemHandler($request->param('ID')); } /** * @param int $itemID * @return UploadField_ItemHandler */ public function getItemHandler($itemID) { return UploadField_ItemHandler::create($this, $itemID); } /** * @param SS_HTTPRequest $request * @return UploadField_ItemHandler */ public function handleSelect(SS_HTTPRequest $request) { if(!$this->canAttachExisting()) return $this->httpError(403); return UploadField_SelectHandler::create($this, $this->getFolderName()); } /** * Given an array of post variables, extract all temporary file data into an array * * @param array $postVars Array of posted form data * @return array List of temporary file data */ protected function extractUploadedFileData($postVars) { // Note: Format of posted file parameters in php is a feature of using // for multiple file uploads $tmpFiles = array(); if( !empty($postVars['tmp_name']) && is_array($postVars['tmp_name']) && !empty($postVars['tmp_name']['Uploads']) ) { for($i = 0; $i < count($postVars['tmp_name']['Uploads']); $i++) { // Skip if "empty" file if(empty($postVars['tmp_name']['Uploads'][$i])) continue; $tmpFile = array(); foreach(array('name', 'type', 'tmp_name', 'error', 'size') as $field) { $tmpFile[$field] = $postVars[$field]['Uploads'][$i]; } $tmpFiles[] = $tmpFile; } } elseif(!empty($postVars['tmp_name'])) { // Fallback to allow single file uploads (method used by AssetUploadField) $tmpFiles[] = $postVars; } return $tmpFiles; } /** * Loads the temporary file data into a File object * * @param array $tmpFile Temporary file data * @param string $error Error message * @return AssetContainer File object, or null if error */ protected function saveTemporaryFile($tmpFile, &$error = null) { // Determine container object $error = null; $fileObject = null; if (empty($tmpFile)) { $error = _t('UploadField.FIELDNOTSET', 'File information not found'); return null; } if($tmpFile['error']) { $error = $tmpFile['error']; return null; } // Search for relations that can hold the uploaded files, but don't fallback // to default if there is no automatic relation if ($relationClass = $this->getRelationAutosetClass(null)) { // Create new object explicitly. Otherwise rely on Upload::load to choose the class. $fileObject = Object::create($relationClass); if(! ($fileObject instanceof DataObject) || !($fileObject instanceof AssetContainer)) { throw new InvalidArgumentException("Invalid asset container $relationClass"); } } // Get the uploaded file into a new file object. try { $this->upload->loadIntoFile($tmpFile, $fileObject, $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->upload->isError()) { $error = implode(' ' . PHP_EOL, $this->upload->getErrors()); return null; } // return file return $this->upload->getFile(); } /** * Safely encodes the File object with all standard fields required * by the front end * * @param AssetContainer $file Object which contains a file * @return array Array encoded list of file attributes */ protected function encodeFileAttributes(AssetContainer $file) { // Collect all output data. $customised = $this->customiseFile($file); return array( 'id' => $file->ID, 'name' => basename($file->getFilename()), 'url' => $file->getURL(), 'thumbnail_url' => $customised->UploadFieldThumbnailURL, 'edit_url' => $customised->UploadFieldEditLink, 'size' => $file->getAbsoluteSize(), 'type' => File::get_file_type($file->getFilename()), 'buttons' => $customised->UploadFieldFileButtons, 'fieldname' => $this->getName() ); } /** * Action to handle upload of a single file * * @param SS_HTTPRequest $request * @return SS_HTTPResponse * @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 $uploadedFiles = $this->extractUploadedFileData($postVars); $return = array(); // Save the temporary files into a File objects // and save data/error on a per file basis foreach ($uploadedFiles as $tempFile) { $file = $this->saveTemporaryFile($tempFile, $error); if(empty($file)) { array_push($return, array('error' => $error)); } else { array_push($return, $this->encodeFileAttributes($file)); } $this->upload->clearErrors(); } // Format response with json $response = new SS_HTTPResponse(Convert::raw2json($return)); $response->addHeader('Content-Type', 'text/plain'); return $response; } /** * Retrieves details for files that this field wishes to attache to the * client-side form * * @param SS_HTTPRequest $request * @return SS_HTTPResponse */ public function attach(SS_HTTPRequest $request) { if(!$request->isPOST()) return $this->httpError(403); if(!$this->canAttachExisting()) return $this->httpError(403); // Retrieve file attributes required by front end $return = array(); $files = File::get()->byIDs($request->postVar('ids')); foreach($files as $file) { $return[] = $this->encodeFileAttributes($file); } $response = new SS_HTTPResponse(Convert::raw2json($return)); $response->addHeader('Content-Type', 'application/json'); return $response; } /** * Check if file exists, both checking filtered filename and exact filename * * @param string $originalFile Filename * @return bool */ protected function checkFileExists($originalFile) { // Check both original and safely filtered filename $nameFilter = FileNameFilter::create(); $filteredFile = $nameFilter->filter($originalFile); // Resolve expected folder name $folderName = $this->getFolderName(); $folder = Folder::find_or_make($folderName); $parentPath = $folder ? $folder->getFilename() : ''; // check if either file exists return File::find($parentPath.$originalFile) || File::find($parentPath.$filteredFile); } /** * Determines if a specified file exists * * @param SS_HTTPRequest $request */ public function fileexists(SS_HTTPRequest $request) { // Assert that requested filename doesn't attempt to escape the directory $originalFile = $request->requestVar('filename'); if($originalFile !== basename($originalFile)) { $return = array( 'error' => _t('File.NOVALIDUPLOAD', 'File is not a valid upload') ); } else { $return = array( 'exists' => $this->checkFileExists($originalFile) ); } // Encode and present response $response = new SS_HTTPResponse(Convert::raw2json($return)); $response->addHeader('Content-Type', 'application/json'); if (!empty($return['error'])) $response->setStatusCode(400); 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; } } } /** * RequestHandler for actions (edit, remove, delete) on a single item (File) of the UploadField * * @author Zauberfisch * @package forms * @subpackages fields-files */ class UploadField_ItemHandler extends RequestHandler { /** * @var UploadFIeld */ protected $parent; /** * @var int FileID */ protected $itemID; private static $url_handlers = array( '$Action!' => '$Action', '' => 'index', ); private static $allowed_actions = array( 'delete', 'edit', 'EditForm', ); /** * @param UploadFIeld $parent * @param int $itemID */ public function __construct($parent, $itemID) { $this->parent = $parent; $this->itemID = $itemID; parent::__construct(); } /** * @return File */ public function getItem() { return DataObject::get_by_id('File', $this->itemID); } /** * @param string $action * @return string */ public function Link($action = null) { return Controller::join_links($this->parent->Link(), '/item/', $this->itemID, $action); } /** * @return string */ public function DeleteLink() { $token = $this->parent->getForm()->getSecurityToken(); return $token->addToUrl($this->Link('delete')); } /** * @return string */ public function EditLink() { return $this->Link('edit'); } /** * Action to handle deleting of a single file * * @param SS_HTTPRequest $request * @return SS_HTTPResponse */ public function delete(SS_HTTPRequest $request) { // Check form field state if($this->parent->isDisabled() || $this->parent->isReadonly()) return $this->httpError(403); // Protect against CSRF on destructive action $token = $this->parent->getForm()->getSecurityToken(); if(!$token->checkRequest($request)) return $this->httpError(400); // Check item permissions $item = $this->getItem(); if(!$item) return $this->httpError(404); if($item instanceof Folder) return $this->httpError(403); if(!$item->canDelete()) return $this->httpError(403); // Delete the file from the filesystem. The file will be removed // from the relation on save // @todo Investigate if references to deleted files (if unsaved) is dangerous $item->delete(); } /** * Action to handle editing of a single file * * @param SS_HTTPRequest $request * @return ViewableData_Customised */ public function edit(SS_HTTPRequest $request) { // Check form field state if($this->parent->isDisabled() || $this->parent->isReadonly()) return $this->httpError(403); // Check item permissions $item = $this->getItem(); if(!$item) return $this->httpError(404); if($item instanceof Folder) return $this->httpError(403); if(!$item->canEdit()) return $this->httpError(403); Requirements::css(FRAMEWORK_DIR . '/css/UploadField.css'); return $this->customise(array( 'Form' => $this->EditForm() ))->renderWith($this->parent->getTemplateFileEdit()); } /** * @return Form */ public function EditForm() { $file = $this->getItem(); if(!$file) return $this->httpError(404); if($file instanceof Folder) return $this->httpError(403); if(!$file->canEdit()) return $this->httpError(403); // Get form components $fields = $this->parent->getFileEditFields($file); $actions = $this->parent->getFileEditActions($file); $validator = $this->parent->getFileEditValidator($file); $form = new Form( $this, __FUNCTION__, $fields, $actions, $validator ); $form->loadDataFrom($file); $form->addExtraClass('small'); return $form; } /** * @param array $data * @param Form $form * @param SS_HTTPRequest $request */ public function doEdit(array $data, Form $form, SS_HTTPRequest $request) { // Check form field state if($this->parent->isDisabled() || $this->parent->isReadonly()) return $this->httpError(403); // Check item permissions $item = $this->getItem(); if(!$item) return $this->httpError(404); if($item instanceof Folder) return $this->httpError(403); if(!$item->canEdit()) return $this->httpError(403); $form->saveInto($item); $item->write(); $form->sessionMessage(_t('UploadField.Saved', 'Saved'), 'good'); return $this->edit($request); } } /** * File selection popup for attaching existing files. * * @package forms * @subpackages fields-files */ class UploadField_SelectHandler extends RequestHandler { /** * @var UploadField */ protected $parent; /** * @var string */ protected $folderName; private static $url_handlers = array( '$Action!' => '$Action', '' => 'index', ); private static $allowed_actions = array( 'Form' ); public function __construct($parent, $folderName = null) { $this->parent = $parent; $this->folderName = $folderName; parent::__construct(); } public function index() { // Requires a separate JS file, because we can't reach into the iframe with entwine. Requirements::javascript(FRAMEWORK_DIR . '/javascript/UploadField_select.js'); return $this->renderWith('CMSDialog'); } /** * @param string $action * @return string */ public function Link($action = null) { return Controller::join_links($this->parent->Link(), '/select/', $action); } /** * Build the file selection form. * * @return Form */ public function Form() { // Find out the requested folder ID. $folderID = $this->parent->getRequest()->requestVar('ParentID'); if ($folderID === null && $this->parent->getDisplayFolderName()) { $folder = Folder::find_or_make($this->parent->getDisplayFolderName()); $folderID = $folder ? $folder->ID : 0; } // Construct the form $action = new FormAction('doAttach', _t('UploadField.AttachFile', 'Attach file(s)')); $action->addExtraClass('ss-ui-action-constructive icon-accept'); $form = new Form( $this, 'Form', new FieldList($this->getListField($folderID)), new FieldList($action) ); // Add a class so we can reach the form from the frontend. $form->addExtraClass('uploadfield-form'); return $form; } /** * @param $folderID The ID of the folder to display. * @return FormField */ protected function getListField($folderID) { // Generate the folder selection field. $folderField = new TreeDropdownField('ParentID', _t('HtmlEditorField.FOLDER', 'Folder'), 'Folder'); $folderField->setValue($folderID); // Generate the file list field. $config = GridFieldConfig::create(); $config->addComponent(new GridFieldSortableHeader()); $config->addComponent(new GridFieldFilterHeader()); $config->addComponent($colsComponent = new GridFieldDataColumns()); $colsComponent->setDisplayFields(array( 'StripThumbnail' => '', 'Title' => singleton('File')->fieldLabel('Title'), 'Created' => singleton('File')->fieldLabel('Created'), 'Size' => singleton('File')->fieldLabel('Size') )); $colsComponent->setFieldCasting(array( 'Created' => 'SS_Datetime->Nice' )); $config->addComponent(new GridFieldPaginator(11)); // If relation is to be autoset, we need to make sure we only list compatible objects. $baseClass = $this->parent->getRelationAutosetClass(); // Create the data source for the list of files within the current directory. $files = DataList::create($baseClass)->exclude('ClassName', 'Folder'); if($folderID) $files = $files->filter('ParentID', $folderID); $fileField = new GridField('Files', false, $files, $config); $fileField->setAttribute('data-selectable', true); if($this->parent->getAllowedMaxFileNumber() !== 1) { $fileField->setAttribute('data-multiselect', true); } $selectComposite = new CompositeField( $folderField, $fileField ); return $selectComposite; } public function doAttach($data, $form) { // Popup-window attach does not require server side action, as it is implemented via JS } }