🦊

cakephp2リプレイスUploadPack(ファイルアップロードプラグイン)の罠

2024/11/13に公開

cakephp2をリプレイスしていてハマった。
ハマったというより脱力した。
どうして...どうしてcakephp2で対応止まっているの...

ということでcomponentとhelper、gdを用いてみたような機能を作成してみる
プラグインにする気はない。(長いしメンテしたくないので)

cakephp5.0.10
php8.3
apache2
ubuntu24.04

汎用的ファイルの作成

src/Controller/Component/BaseTrait.php
<?php

declare(strict_types=1);

namespace App\Controller\Component;

use Cake\ORM\TableRegistry;

trait BaseTrait {
    protected function controller() {
        return $this->_registry->getController();
    }
    protected function request() {
        return $this->controller()->getRequest();
    }
    protected function response() {
        return $this->controller()->getResponse();
    }
    protected function session() {
        return $this->controller()->getRequest()->getSession();
    }
    protected function getTable($table) {
        return TableRegistry::getTableLocator()->get($table);
    }
}
src/Controller/AppController.php
<?php

declare(strict_types=1);

/**
 * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
 * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
 *
 * Licensed under The MIT License
 * For full copyright and license information, please see the LICENSE.txt
 * Redistributions of files must retain the above copyright notice.
 *
 * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
 * @link      https://cakephp.org CakePHP(tm) Project
 * @since     0.2.9
 * @license   https://opensource.org/licenses/mit-license.php MIT License
 */

namespace App\Controller;

use Cake\Controller\Controller;
use Cake\Datasource\ConnectionManager;

/**
 * Application Controller
 *
 * Add your application-wide methods in the class below, your controllers
 * will inherit them.
 *
 * @link https://book.cakephp.org/5/en/controllers.html#the-app-controller
 * @property \Authentication\Controller\Component\AuthenticationComponent $Authentication
 * @property \App\Controller\Component\CommonComponent $Common
 */
class AppController extends Controller
{
    /**
     * Initialization hook method.
     *
     * Use this method to add common initialization code like loading components.
     *
     * e.g. `$this->loadComponent('FormProtection');`
     *
     * @return \Cake\Http\Response|null|void
     */
    public function initialize(): void
    {
        parent::initialize();

        $this->loadComponent('Flash');

        /*
         * Enable the following component for recommended CakePHP form protection settings.
         * see https://book.cakephp.org/5/en/controllers/components/form-protection.html
         */
        //$this->loadComponent('FormProtection');
        $this->loadComponent('Authentication.Authentication');
        $this->loadComponent('ImageUP');
    }

    /**
     * session function
     *
     * セッション
     *
     * @return \Cake\Http\Session
     */
    public function session()
    {
        return $this->getRequest()->getSession();
    }

    /**
     * connection function
     *
     * トランザクション用
     *
     * @return \Cake\Datasource\ConnectionInterface
     */
    public function connection()
    {
        return ConnectionManager::get('default');
    }

    /**
     * getLoginUser function
     *
     * ログインしているユーザーの情報を取得する
     *
     * @return \Authentication\IdentityInterface|null
     */
    public function getLoginUser($key)
    {
        $identity = $this->Authentication->getIdentity();
        if ($key && $identity) {
            return $identity[$key];
        }
        return $identity;
    }
}

src/Controller/Component/ImageUPComponent.php
<?php

declare(strict_types=1);

namespace App\Controller\Component;

use Cake\Controller\Component;
use Cake\Controller\ComponentRegistry;
use App\Controller\Component\BaseTrait;
use Exception;

/**
 * Common component
 */
class ImageUPComponent extends Component
{
    use BaseTrait;

    // 画像保存で生成される画像リスト
    protected array $createImageSet = [
        '_thumb.' =>
        [
            'max_width' => 100,
            'max_height' => 100
        ]
    ];

    /**
     * initialize function
     *
     * @param array $config
     * @return \Cake\Http\Response|null|void
     */
    public function initialize(array $config): void
    {
        parent::initialize($config);
    }

    /**
     * upload function
     *
     * 画像のアップロード
     *
     * @param entity $imageEntity 対象テーブルのエンティティ
     * @param mixed $imageFile 画像ファイル
     * @return bool
     */
    public function upload($imageEntity, $imageFile):bool
    {
        // 元画像の名前を取得
        $name = $imageFile->getClientFilename();
        // 元画像の名前(拡張子抜き)
        $this->extension = pathinfo($name, PATHINFO_EXTENSION);
        // 元画像の拡張子
        $this->filename = pathinfo($name, PATHINFO_FILENAME);
        // inputされたファイルのpath
        $this->image_uri = $imageFile->getStream()->getMetadata('uri');
        // 元動画のサイズ
        $this->image_size_width = getimagesize($this->image_uri)[0];
        $this->image_size_height = getimagesize($this->image_uri)[1];
        // 元画像の縦横比
        $this->aspect = $this->image_size_height / $this->image_size_width;
        $this->model = strtolower($imageEntity->modelName);

        $dirPath = WWW_ROOT . 'uploads' . DS . $this->model . DS . $imageEntity['id'];
        // ディレクトリが存在するかチェック
        if (!file_exists($dirPath)) {
            // フォルダを作成(0775はフォルダのパーミッション)
            mkdir($dirPath, 0775, true);
        }

        // 元画像をwebroot内に保存する
        $image_name =  $this->filename . '_original.' . $this->extension;
        $this->original_path = WWW_ROOT . 'uploads' . DS . $this->model . DS . $imageEntity['id'] . DS . $image_name;

        if ($image_name) {
            $imageFile->moveTo($this->original_path);
            // サムネを作る
            // 元画像をGDに変換する
            $original_image = $this->loadImageToGD($this->original_path);

            foreach ($this->createImageSet as $key => $val) {
                $create_thumb = $this->createImage($original_image, $imageEntity['id'], $key, (int)$val['max_width'], (int)$val['max_height']);
                (!$create_thumb) ? throw new Exception('保存失敗') : '';
            }

            // GDのメモリを解放する
            imagedestroy($original_image);
            return true;
        } else {
            return false;
        }
    }

    /**
     * createImage function
     *
     * 元画像を元にサイズ変更した画像を作成
     *
     * @param mixed $original_image_gd 元動画のGD
     * @param int $id
     * @param string $style _thumb.
     * @param int $max_width 作成する画像の幅
     * @param int $max_height 作成する画像の高さ
     * @return bool
     */
    private function createImage($original_image_gd, $id, $style, $max_width, $max_height):bool
    {
        $image_name =  $this->filename . $style . $this->extension;
        $save_path = WWW_ROOT . 'uploads' . DS . $this->model . DS . $id . DS . $image_name;

        // 新しい画像をGDで作成
        $new_image = imagecreatetruecolor($max_width, $max_height);
        // 背景を白にする
        $white = imagecolorallocate($new_image, 255, 255, 255);
        imagefill($new_image, 0, 0, $white);
        // 元画像の縦横比を合わせたwidthとheightを計算する
        $new_size = $this->calcAspect($max_width, $max_height);
        // 中央に画像を配置するための座標を計算
        $x_offset = ($max_width - $new_size['width']) / 2;
        $y_offset = ($max_height - $new_size['height']) / 2;
        // 画像をコピーしてリサイズ
        imagecopyresampled($new_image, $original_image_gd, (int)$x_offset, (int)$y_offset, 0, 0, $new_size['width'], $new_size['height'], $this->image_size_width, $this->image_size_height);

        // 拡張子に合わせて保存する
        switch ($this->extension) {
            case 'jpg':
            case 'jpeg':
                imagejpeg($new_image, $save_path);
                break;
            case 'png':
                imagepng($new_image, $save_path);
                break;
            case 'webp':
                imagewebp($new_image, $save_path);
                break;
        }
        // メモリを解放
        imagedestroy($new_image);

        return true;
    }

    /**
     * calcAspect function
     *
     * 元画像の縦横比と合わせた幅と高さを返す
     *
     * @param int $max_width 幅
     * @param int $max_height 高さ
     * @return array
     */
    private function calcAspect($max_width, $max_height):array
    {
        // 幅を基準に高さを計算
        $new_width = $max_width;
        $new_height = $new_width * $this->aspect;

        // 高さが最大高さを超えた場合、高さを基準に幅を再計算
        if ($new_height > $max_height) {
            $new_height = $max_height;
            $new_width = $new_height / $this->aspect;
        }

        return [
            'width' => (int)$new_width,
            'height' => (int)$new_height
        ];
    }

    /**
     * loadImageToGD function
     *
     * 画像をgdに変換する
     *
     * @param string $path フルパス
     * @return resource|\GdImage|false
     */
    private function loadImageToGD($path)
    {
        switch ($this->extension) {
            case 'jpg':
            case 'jpeg':
                $original_image = imagecreatefromjpeg($path);
                break;
            case 'png':
                $original_image = imagecreatefrompng($path);
                break;
            case 'webp':
                $original_image = imagecreatefromwebp($path);
                break;
        }

        return $original_image;
    }

    /**
     * delete function
     *
     * @param int $id
     * @param string $image filename
     * @return \Cake\Http\Response|null|void
     */
    public function delete($entity,$id, $image)
    {
        $filename = pathinfo($image, PATHINFO_FILENAME);
        $path = WWW_ROOT . 'uploads' . DS . strtolower($entity->modelName) . DS . $id . DS . $filename . '*';
        $files = glob($path);

        foreach ($files as $file) {
            if (file_exists($file)) {
                // ファイルを削除
                unlink($file);
            }
        }
        return;
    }

    /**
     * uploadFile function
     *
     * 画像ではなくオリジナルファイルしか保存しないものをアップロードする
     *
     * @param [type] $imageEntity
     * @param [type] $imageFile
     * @return boolean
     */
    public function uploadFile($imageEntity, $imageFile):bool
    {
        // 元画像の名前を取得
        $name = $imageFile->getClientFilename();
        // 元画像の名前(拡張子抜き)
        $this->extension = pathinfo($name, PATHINFO_EXTENSION);
        // 元画像の拡張子
        $this->filename = pathinfo($name, PATHINFO_FILENAME);
        $this->model = strtolower($imageEntity->modelName);

        $dirPath = WWW_ROOT . 'uploads' . DS . $this->model . DS . $imageEntity['id'];
        // ディレクトリが存在するかチェック
        if (!file_exists($dirPath)) {
            // フォルダを作成(0775はフォルダのパーミッション)
            mkdir($dirPath, 0775, true);
        }

        // 元画像をwebroot内に保存する
        $image_name =  $this->filename . '_original.' . $this->extension;
        $this->original_path = WWW_ROOT . 'uploads' . DS . $this->model . DS . $imageEntity['id'] . DS . $image_name;

        if ($image_name) {
            $imageFile->moveTo($this->original_path);

            return true;
        } else {
            return false;
        }
    }
}

src/View/Helper/CustomLinkHelper.php
<?php

declare(strict_types=1);

namespace App\View\Helper;

use Cake\View\Helper;
use Cake\View\View;

/**
 * CustomLink helper
 *
 * @property \Cake\View\Helper\HtmlHelper $Html
 * @property \Cake\View\Helper\FormHelper $Form
 * @property \Cake\View\Helper\UrlHelper $Url
 */
class CustomLinkHelper extends Helper
{
    /**
     * Default configuration.
     *
     * @var array<string, mixed>
     */
    protected array $_defaultConfig = [];
    protected array $helpers = ['Html', 'Form', 'Url'];

    /**
     * getOriginal function
     *
     * オリジナル用のリンクを返す
     *
     * @param entity $entity 
     * @param string $filename 画像ファイル名(拡張子付き)
     * @param string $class 付与したいclassがあれば
     * @param string $wight 付与したいwightがあれば初期値は100
     * @return void
     */
    public function getOriginal($entity, $filename, $class = '', $width = '', $height = '')
    {
        $extension = pathinfo($filename, PATHINFO_EXTENSION);
        $filename = pathinfo($filename, PATHINFO_FILENAME);
        $path = '/uploads/' . strtolower($entity->modelName) . '/' . $entity->id . '/' . $filename . '_original.' . $extension;
        return $this->Html->image($path, ['class' => $class, 'width' => $width, 'height' => $height, 'alt' => $filename]);
    }
    /**
     * getOriginalUrl function
     *
     * オリジナル用のURLを返す
     *
     * @param entity $entity 
     * @param string $filename 画像ファイル名(拡張子付き)
     * @return void
     */
    public function getOriginalUrl($entity, $filename)
    {
        $extension = pathinfo($filename, PATHINFO_EXTENSION);
        $filename = pathinfo($filename, PATHINFO_FILENAME);
        $path = '/uploads/' . strtolower($entity->modelName) . '/' . $entity->id . '/' . $filename . '_original.' . $extension;
        return $this->Url->build($path);
    }
    /**
     * getThumbnail function
     *
     * サムネイル用のリンクを返す
     *
     * @param mixed $entity 
     * @param string $filename 画像ファイル名(拡張子付き)
     * @param string $class 付与したいclassがあれば
     * @param string $wight 付与したいwightがあれば初期値は100
     * @return void
     */
    public function getThumbnail($entity, $filename, $class = '', $width = '100', $height = '')
    {
        $extension = pathinfo($filename, PATHINFO_EXTENSION);
        $filename = pathinfo($filename, PATHINFO_FILENAME);
        $path = '/uploads/'. strtolower($entity->modelName) . '/' . $entity->id . '/' . $filename . '_thumb.' . $extension;
        return $this->Html->image($path, ['class' => $class, 'width' => $width, 'height' => $height,'alt' => $filename]);
    }
}

<?php

namespace App\Model\Entity;

trait BaseTrait
{
    public function _getModelName()
    {
        return $this->getSource();
    }

    public function mergeVirtualProperties() {
        // 仮想プロパティをマージする処理
        $this->_virtual = array_merge($this->_virtual, $this->getTraitVirtual());
    }
}

画像のfile_nameが保存されているentityでBaseTraitを有効にする

src/Model/Entity/Image.php
// 略
class Image extends Entity
{
    use BaseTrait;
// 略

呼び出し方

image画像として表示する

第一引数:エンティティ ($this->Models->get(id)で出てくるようなやつ)
第二引数:登録してある画像名
第三引数:追加したいクラスがあれば
第四引数:width
第五引数:height

<?= $this->CustomLink->getOriginal($image, $image['image_file_name']) ?>
<?= $this->CustomLink->getThumbnail($image, $image['image_file_name']) ?>

<a href="<?= $this->CustomLink->getOriginalUrl($image, $image['image_file_name']) ?>" target="_blank"></a>

画像を保存する

    public function add()
    {
        $errors = [];
        $uploadFile = [];
        $uploadImage = [];
        $images = $this->fetchTable('Images')->newEmptyEntity();

        if ($this->request->is('post')) {
            $input = $this->getRequest()->getData();
            // submitされたすべてのファイルを取得
            $pictures = $this->request->getUploadedFiles();

            // 画像(サムネとオリジナルを保存)
            foreach ($pictures['image'] as $key => $picture) {
                if ($picture->getClientFilename() === '') {
                    continue;
                }
                $uploadImage[$key] = $picture;
                $input[$key . '_file_name'] = $picture->getClientFilename();
            }
            // ファイル
            foreach ($pictures['file'] as $key => $file) {
                if ($file->getClientFilename() === '') {
                    continue;
                }
                $uploadFile[$key] = $file;
                $input[$key . '_file_name'] = $file->getClientFilename();
            }

            $image = $this->fetchTable('Images')->patchEntity($images, $input);

            if ($image->hasErrors()) {
                $errors = $image->getErrors();
            } else {
                try {
                    $this->connection()->transactional(function () use ($uploadImage, $uploadFile, $image) {
                        if ($this->fetchTable('Images')->save($image)) {

                            // 画像(サムネとオリジナルを保存)
                            if (!empty($uploadImage)) {
                                foreach ($uploadImage as $save_image) {
                                    if (!$this->ImageUP->upload($image, $save_image)) {
                                        throw new Exception('画像登録失敗');
                                    };
                                }
                            }

                            // ファイル
                            if (!empty($uploadFile)) {
                                foreach ($uploadFile as $file) {
                                    if (!$this->ImageUP->uploadFile($image, $file)) {
                                        throw new Exception('ファイル登録失敗');
                                    };
                                }
                            }
                        } else {
                            throw new Exception('images登録失敗');
                        }
                    });
                    return $this->redirect(['action' => 'addComplete']);
                } catch (Exception $e) {
                    $this->log('images登録失敗' . $e->getMessage());
                }
            }
        }
        $this->set(compact('image,'errors'));
    }

あんまり綺麗に描けてないな〜

Discussion