diff --git a/composer.json b/composer.json index 7b449ba..1cb9bff 100644 --- a/composer.json +++ b/composer.json @@ -1,45 +1,51 @@ -{ - "name": "rkit/filemanager-yii2", - "description": "FileManager for Yii2", - "keywords": ["yii2", "extension", "file manager", "manager", "upload", "resize", "files"], - "homepage": "https://github.com/rkit/filemanager-yii2", - "type": "yii2-extension", - "license": "MIT", - "authors": [ - { - "name": "Igor Romanov", - "email": "rkit.ru@gmail.com" - } - ], - "support": { - "issues": "https://github.com/rkit/filemanager-yii2/issues?state=open", - "source": "https://github.com/rkit/filemanager-yii2" - }, - "require": { - "yiisoft/yii2": "^2.0" - }, - "scripts": { - "phpdoc": "phpdoc", - "test-prepare": [ - "php tests/yii migrate --migrationPath=@tests/migrations/ --interactive=0" - ], - "test": "phpunit --colors=always", - "test-coverage": "phpunit --coverage-html=tests/tmp/coverage.html --colors=always", - "test-coverage-open": "open tests/tmp/coverage.html/index.html" - }, - "autoload": { - "psr-4": { - "rkit\\filemanager\\": "src" - } - }, - "require-dev": { - "intervention/image": "^2.3.2", - "phpunit/phpunit": "^4.8", - "phpunit/dbunit": "^1.4", - "phpdocumentor/phpdocumentor": "^2.8.0", - "cvuorinen/phpdoc-markdown-public": "^0.1.2", - "league/flysystem": "^1.0", - "creocoder/yii2-flysystem": "^0.8.1", - "squizlabs/php_codesniffer": "3.0.0RC1" - } -} +{ + "name": "rkit/filemanager-yii2", + "description": "FileManager for Yii2", + "keywords": ["yii2", "extension", "file manager", "manager", "upload", "resize", "files"], + "homepage": "https://github.com/rkit/filemanager-yii2", + "type": "yii2-extension", + "version": "5.8.1", + "license": "MIT", + "authors": [ + { + "name": "Igor Romanov", + "email": "rkit.ru@gmail.com" + }, + { + "name": "Sergii Gamaiunov", + "email": "devkadabra@gmail.com" + } + ], + "support": { + "issues": "https://github.com/rkit/filemanager-yii2/issues?state=open", + "source": "https://github.com/rkit/filemanager-yii2" + }, + "require": { + "yiisoft/yii2": "^2.0", + "igogo5yo/yii2-upload-from-url": "^1.4" + }, + "scripts": { + "phpdoc": "phpdoc", + "test-prepare": [ + "php tests/yii migrate --migrationPath=@tests/migrations/ --interactive=0" + ], + "test": "phpunit --colors=always", + "test-coverage": "phpunit --coverage-html=tests/tmp/coverage.html --colors=always", + "test-coverage-open": "open tests/tmp/coverage.html/index.html" + }, + "autoload": { + "psr-4": { + "rkit\\filemanager\\": "src" + } + }, + "require-dev": { + "intervention/image": "^2.3.2", + "phpunit/phpunit": "^4.8", + "phpunit/dbunit": "^1.4", + "phpdocumentor/phpdocumentor": "^2.8.0", + "cvuorinen/phpdoc-markdown-public": "^0.1.2", + "league/flysystem": "*", + "creocoder/yii2-flysystem": "*", + "squizlabs/php_codesniffer": "3.0.0RC1" + } +} diff --git a/guide/README.md b/guide/README.md index a12f1d3..e0efa75 100644 --- a/guide/README.md +++ b/guide/README.md @@ -39,6 +39,13 @@ Let's do it. ``` > You can add any extra fields, such as `type` to divide files by type or `position` to set sort order + Migration for upload session (since 5.0) + + ```php + php yii migrate/create create_file_upload_session_table --fields="file_id:integer:notNull:defaultValue(0),created_user_id:integer:notNull:defaultValue(0),target_model_id:integer:defaultValue(0),target_model_class:string:notNull:defaultValue(''),target_model_attribute:string:notNull:defaultValue(''),created_on:datetime" + ``` + > You can add any extra fields, such as `type` to divide files by type or `position` to set sort order + 3. **Applying Migrations** ```php @@ -119,6 +126,17 @@ Let's do it. $file->save(); return $file; }, + // a callback for creating `File` model for remote uploads + 'createRemoteFile' => function ($info, $name) { + $file = new File(); + $file->title = $name; + $file->created_on = new yii\db\Expression('NOW()'); + $file->created_user_id = Yii::$app->user->id; + $file->size_bytes = $info->size; + $file->generateName($info->url, $name, $this); + $file->save(); + return $file; + }, // core validators 'rules' => [ 'imageSize' => ['minWidth' => 300, 'minHeight' => 300], diff --git a/src/FileManager.php b/src/FileManager.php index ac7acc2..d0dbfa6 100644 --- a/src/FileManager.php +++ b/src/FileManager.php @@ -18,11 +18,6 @@ */ class FileManager extends Component { - /** - * @var string Session variable name - */ - public $sessionName = 'filemanager.uploads'; - /** * @internal */ diff --git a/src/actions/UploadAction.php b/src/actions/UploadAction.php index c342197..ae8ebb0 100644 --- a/src/actions/UploadAction.php +++ b/src/actions/UploadAction.php @@ -45,6 +45,13 @@ class UploadAction extends Action * @var bool $saveAfterUpload Save after upload */ public $saveAfterUpload = false; + /** + * @var callable $onSuccess Function to be returned after successful upload instead of responce. Function signature + * is ```function (File $file, ActiveRecord $model){}```, where `File` is the model that's configured in `createFile` + * option of model's file behavior configuration + * @since 5.3.0 + */ + public $onSuccess; /** * @var ActiveRecord $model */ @@ -111,6 +118,14 @@ private function upload($file) if (count($presetAfterUpload)) { $this->applyPreset($presetAfterUpload, $file); } + if ($this->onSuccess) { + $responce = call_user_func($this->onSuccess, $file, $this->model); + if (is_array($responce)) { + return $this->controller->asJson($responce); + } else { + return $responce; + } + } $template = $this->model->fileOption($this->attribute, 'template'); if ($template) { return $this->response( diff --git a/src/behaviors/FileBehavior.php b/src/behaviors/FileBehavior.php index 4dd21a9..5fe7b9b 100644 --- a/src/behaviors/FileBehavior.php +++ b/src/behaviors/FileBehavior.php @@ -8,8 +8,10 @@ namespace rkit\filemanager\behaviors; +use rkit\filemanager\models\FileUploadSession; use Yii; use yii\base\Behavior; +use yii\base\Exception; use yii\db\ActiveRecord; use yii\helpers\ArrayHelper; @@ -19,15 +21,33 @@ class FileBehavior extends Behavior * @var array */ public $attributes = []; + /** * @var ActiveQuery */ private $relation; + /** * @var FileBind */ private $fileBind; + /** + * @var array + */ + protected static $classPathMap = []; + + /** + * @var string name of application component that represents `user` + */ + public $userComponent = 'user'; + + /** + * @since 5.6.0 + * @var bool + */ + protected $markedLinked = false; + /** * @internal */ @@ -76,7 +96,13 @@ public function beforeSave($insert) */ public function afterSave() { - foreach ($this->attributes as $attribute => $options) { + foreach ($this->attributes as $attribute => $options) + { + $disableAutobind = $this->fileOption($attribute, 'disableAutobind'); + if ($disableAutobind) { + continue; + } + $files = $this->owner->{$attribute}; $isAttributeNotChanged = $options['isAttributeChanged'] === false || $files === null; @@ -103,12 +129,13 @@ public function afterSave() } $files = $this->fileBind->bind($this->owner, $attribute, $files); + + $this->clearState($attribute, $files); + if (is_array($files)) { $files = array_shift($files); + $this->setValue($attribute, $files, $options['oldValue']); } - - $this->clearState($attribute); - $this->setValue($attribute, $files, $options['oldValue']); } } @@ -119,27 +146,62 @@ public function afterSave() public function beforeDelete() { foreach ($this->attributes as $attribute => $options) { - $this->fileBind->delete($this->owner, $attribute, $this->files($attribute)); + $disableAutobind = $this->fileOption($attribute, 'disableAutobind'); + if (!$disableAutobind) { + $this->fileBind->delete($this->owner, $attribute, $this->files($attribute)); + } } } - private function clearState($attribute) + protected function getUser() { - $state = Yii::$app->session->get(Yii::$app->fileManager->sessionName); - unset($state[$attribute]); - Yii::$app->session->set(Yii::$app->fileManager->sessionName, $state); + if (!$this->userComponent || !isset(Yii::$app->{$this->userComponent})) { + return false; + } + return Yii::$app->{$this->userComponent}; } - private function setState($attribute, $file) + public function clearState($attribute, $files) { - $state = Yii::$app->session->get(Yii::$app->fileManager->sessionName); - if (!is_array($state)) { - $state = []; + if (!$this->getUser()) { + return []; + } + if (!is_array($files)) { + $files = [$files]; } - $state[$attribute][] = $file->getPrimaryKey(); - Yii::$app->session->set(Yii::$app->fileManager->sessionName, $state); + $query = [ + 'created_user_id' => $this->getUser()->id, + 'target_model_class' => static::getClass(get_class($this->owner)), + 'target_model_id' => $this->owner->getPrimaryKey(), + 'target_model_attribute' => $attribute, + ]; + if ($files) { + $fileIDs = ArrayHelper::getColumn($files, 'id'); + $query['file_id'] = $fileIDs; + } + FileUploadSession::deleteAll($query); + $query['target_model_id'] = null; + FileUploadSession::deleteAll($query); // for cases of uploads when original model was a new record at the moment of uploads + return; + } + + private function setState($attribute, $file) + { + $rec = new FileUploadSession(); + $rec->created_user_id = $this->getUser()->id; + $rec->file_id = $file->getPrimaryKey(); + $rec->target_model_attribute = $attribute; // TODO: write model/object id? + $rec->target_model_id = (!$this->owner->isNewRecord ? $this->owner->getPrimaryKey() : null); + $rec->target_model_class = static::getClass(get_class($this->owner)); + $rec->save(false); } + /** + * for models with single upload only + * @param $attribute + * @param $file + * @param $defaultValue + */ private function setValue($attribute, $file, $defaultValue) { $saveFilePath = $this->fileOption($attribute, 'saveFilePathInAttribute'); @@ -198,13 +260,14 @@ private function templatePath($attribute, $file = null) $saveFileId = $this->fileOption($attribute, 'saveFileIdInAttribute'); $isFilledId = $saveFileId && is_numeric($value) && $value; - if (($isFilledPath || $isFilledId) && $file === null) { - $file = $this->file($attribute); - } - - if ($file !== null) { - $handlerTemplatePath = $this->fileOption($attribute, 'templatePath'); - return $handlerTemplatePath($file); + if ($this->fileOption($attribute, 'disableAutobind')) { + if (($isFilledPath || $isFilledId) && $file === null) { + $file = $this->file($attribute); + } + if ($file !== null) { + $handlerTemplatePath = $this->fileOption($attribute, 'templatePath'); + return $handlerTemplatePath($file); + } } return $value; } @@ -257,7 +320,9 @@ public function fileStorage($attribute) public function filePath($attribute, $file = null) { $path = $this->templatePath($attribute, $file); - return $this->fileStorage($attribute)->path . $path; + /** @var Filesystem $fs */ + $fs = $this->fileStorage($attribute); + return $fs->getAdapter()->getPathPrefix() . $path; } /** @@ -281,6 +346,9 @@ public function fileUrl($attribute, $file = null) */ public function fileExtraFields($attribute) { + if ($this->fileOption($attribute, 'disableAutobind')) { + return []; + } $fields = $this->fileBind->relations($this->owner, $attribute); if (!$this->fileOption($attribute, 'multiple')) { return array_shift($fields); @@ -296,6 +364,9 @@ public function fileExtraFields($attribute) */ public function files($attribute) { + if ($this->fileOption($attribute, 'disableAutobind')) { + throw new Exception('Accessing `files()` is not allowed when auto-bind is disabled, see `FileBehavior::$disableAutobind`'); + } return $this->fileBind->files($this->owner, $attribute); } @@ -307,6 +378,9 @@ public function files($attribute) */ public function file($attribute) { + if ($this->fileOption($attribute, 'disableAutobind')) { + throw new Exception('Accessing `file()` is not allowed when auto-bind is disabled, see `FileBehavior::$disableAutobind`'); + } return $this->fileBind->file($this->owner, $attribute); } @@ -335,8 +409,24 @@ public function fileRules($attribute, $onlyCoreValidators = false) */ public function fileState($attribute) { - $state = Yii::$app->session->get(Yii::$app->fileManager->sessionName); - return ArrayHelper::getValue($state === null ? [] : $state, $attribute, []); + if (!$this->getUser()) { + return []; + } + $query = FileUploadSession::find()->where([ + 'created_user_id' => $this->getUser()->id, + 'target_model_class' => static::getClass(get_class($this->owner)), + 'target_model_attribute' => $attribute, + ]); + $query->andWhere(['or', + ['target_model_id' => $this->owner->getPrimaryKey()], + ['target_model_id' => null] // for cases of uploads when original model was a new record at the moment of uploads + ]); + $data = $query->all(); + if ($data) { + return ArrayHelper::getColumn($data, ['file_id']); + } else { + return []; + } } /** @@ -402,12 +492,95 @@ public function createFile($attribute, $path, $name) $storage = $this->fileStorage($attribute); $contents = file_get_contents($path); $handlerTemplatePath = $this->fileOption($attribute, 'templatePath'); - if ($storage->write($handlerTemplatePath($file), $contents)) { - $this->setState($attribute, $file); + if ($storage->write($handlerTemplatePath($file), $contents, [ + // set correct mime type: + 'mimetype' => yii\helpers\FileHelper::getMimeTypeByExtension($name), + ])) { + $disableAutobind = $this->fileOption($attribute, 'disableAutobind'); + if (!$this->markedLinked && !$disableAutobind) { + $this->setState($attribute, $file); + } + $this->owner->{$attribute} = $file->id; + return $file; + } + } // @codeCoverageIgnore + return false; // @codeCoverageIgnore + } + + /** + * Create a file from remote URL + * + * @author Sergii Gamaiunov + * + * @param string $attribute The attribute name + * @param \igogo5yo\uploadfromurl\UploadFromUrl $remoteFile + * @param string $name The file name + * @return \ActiveRecord The file model + */ + public function createRemoteFile($attribute, $remoteFile, $name) + { + $url = $remoteFile->url; + $handlerCreateFile = $this->fileOption($attribute, 'createRemoteFile'); + $file = $handlerCreateFile($remoteFile, $name); + if ($file) { + $storage = $this->fileStorage($attribute); + $stream = fopen($url, 'r'); + $handlerTemplatePath = $this->fileOption($attribute, 'templatePath'); + if ($storage->putStream($handlerTemplatePath($file), $stream)) { + if (is_resource($stream)) { // some adapters close resources on their own + fclose($stream); + } + if ($this->getUser()) { + if (!$this->markedLinked) { + $this->setState($attribute, $file); + } + } $this->owner->{$attribute} = $file->id; return $file; } } // @codeCoverageIgnore return false; // @codeCoverageIgnore } + + /** + * Add class alias to be able to upload files for different versions of a model to a single API endpoint + * + * Example: + * ``` + * class OldCar extends Car + * { + * public function init() + * { + * parent::init(); + * $this->car_type = 'old; + * FileBehavior::addClassAlias(get_class($this), Car::className()); + * } + * + * public function formName() { + * return 'Car'; + * } + * } + * ``` + * @param $source + * @param $mapTo + */ + public static function addClassAlias($source, $mapTo) { + static::$classPathMap[$source] = $mapTo; + } + + protected static function getClass($source) { + return isset(static::$classPathMap[$source]) + ? static::$classPathMap[$source] + : $source; + } + + /** + * Mark current upload session as already linked (e.g. file is linked during `createFile`) to avoid duplicate links + * @return $this + * @since 5.6.0 + */ + public function markLinked() { + $this->markedLinked = true; + return $this; + } } diff --git a/src/behaviors/FileBind.php b/src/behaviors/FileBind.php index 0062042..09e77c6 100644 --- a/src/behaviors/FileBind.php +++ b/src/behaviors/FileBind.php @@ -175,7 +175,7 @@ public function delete($model, $attribute, $files) foreach ($presets as $preset) { $thumbPath = $model->thumbPath($attribute, $preset, $file); $filePath = str_replace($storage->path, '', $thumbPath); - if ($storage->has($filePath)) { + if ($storage->fileExists($filePath)) { $storage->delete($filePath); } } @@ -185,7 +185,7 @@ public function delete($model, $attribute, $files) current($relation->link) => $file->getPrimaryKey() ])->execute(); $filePath = $handlerTemplatePath($file); - if ($storage->has($filePath)) { + if ($storage->fileExists($filePath)) { $storage->delete($filePath); } } diff --git a/src/models/FileUploadSession.php b/src/models/FileUploadSession.php new file mode 100644 index 0000000..112275b --- /dev/null +++ b/src/models/FileUploadSession.php @@ -0,0 +1,52 @@ + + * + * Copyright (C) 2018-present Sergii Webkadabra + */ + +namespace rkit\filemanager\models; +use yii\behaviors\BlameableBehavior; +use yii\behaviors\TimestampBehavior; +use yii\db\Expression; + +/** + * Class FileUploadSession + * @package rkit\filemanager\models + * + * @property int $id + * @property int $file_id + * @property int $created_user_id + * @property string $created_on + * @property string $target_model_class + * @property string $target_model_id + * @property string $target_model_attribute + */ +class FileUploadSession extends \yii\db\ActiveRecord +{ + + /** + * @inheritdoc + */ + public static function tableName() + { + return '{{%file_upload_session}}'; + } + + public function behaviors() + { + return [ + [ + 'class' => BlameableBehavior::class, + 'createdByAttribute' => 'created_user_id', + 'updatedByAttribute' => false, + ], + [ + 'class' => TimestampBehavior::class, + 'createdAtAttribute' => 'created_on', + 'updatedAtAttribute' => false, + 'value' => new Expression('NOW()'), + ], + ]; + } +}