Cakephp4にMaintenance plugin for CakePHPを実装

Cakephp4(4.4.14)でメンテナンス画面を表示させるときにイロイロ修正した部分のメモです

# composer
まずcomposerにてMaintenance Plugin for CakePHPを対象のCakephpに導入します
~~~
composer require fusic/maintenance
~~~

# Application.php
useでインポートします
~~~src/Application.php
namespace App;
// ...
//add
use Maintenance\Middleware\MaintenanceMiddleware;
use Cake\Http\Exception\ForbiddenException;
// ...
~~~

middlewareにMaintenanceMiddlewareを追加します
~~~src/Application.php
    public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue
    {
        $middlewareQueue
            ->add(new ErrorHandlerMiddleware(Configure::read('Error')))

            // ...

            ->add(new MaintenanceMiddleware([
                'enabled' => isMaintenance, // メンテナンスモードを有効にするかどうか
                'exception' => new ForbiddenException(), // 例外を指定
                'allowIp' => ['127.0.0.1'], // メンテナンスモードを無効化する IP アドレスリスト
            ]));
        return $middlewareQueue;
    }
~~~

fusic/maintenanceのGithubでは、「tmp/maintenance」の存在で有効無効を判別する方針みたいですが、今回私はisMaintenanceはconst.phpで定数を設定し、メンテナンスモードの切り替えをconst.phpで行う手法にしました。直接にtrue falseを指定しても大丈夫です。

(参考)CakePHP4で定数を定義する方法(外部サイト)▶https://417.run/pg/php/cakephp4/cakephp4-const/
~~~ config/const.php
define('isMaintenance',true);
// ...
~~~
~~~ bootstrap.php
// ...
try {
    Configure::config('default', new PhpConfig());
    Configure::load('app', 'default', false);
    Configure::load('const', 'default');//ここでconst.phpを読み込み
} catch (\Exception $e) {
    exit($e->getMessage() . "\n");
}
// ...
~~~

# メンテナンスページの作成
メンテナンス時に表示する画面テンプレートを作成します
~~~ src/Template/Error/maintenance.php
<p>maintenance page. </p>
~~~

# MaintenanceMiddlewareの編集
最後の肝になります
私の環境では通常のインストール手順ではエラーが多発しましたので、MaintenanceMiddleware.phpを修正しました。
面倒くさいのでそのまま貼り付けますので参考にしてください。
~~~ vendor/cakephp/cakephp/fusic/maintenance/src/Middleware/MaintenanceMiddleware.php
<?php
namespace Maintenance\Middleware;

use Cake\Core\InstanceConfigTrait;
use Cake\Utility\Inflector;
use Cake\View\ViewBuilder;
use Cake\Network\Request;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Cake\Http\Response;

class MaintenanceMiddleware implements MiddlewareInterface
{
    use InstanceConfigTrait;

    /**
     * Default config.
     *
     * @var array
     */
    protected $_defaultConfig = [
        'allowIp' => [],

        'className' => 'Cake\View\View',
        'templatePath' => 'Error',

        'statusFilePath' => TMP,
        'statusFileName' => 'maintenance',
        'statusCode' => 503,

        'ctpFileName' => 'maintenance',
//        'ctpExtension' => '.ctp',

        'contentType' => 'text/html',

        'useXForwardedFor' => false,
    ];


    public function __construct($config = [])
    {
        $this->setConfig($config);
    }

    public function __invoke($request, $response, $next)
    {
        $isActive = $this->isMaintenance($request);

        if ($isActive === false) {
            $response = $next($request, $response);
        } else {
            $response = $this->execute($response);
        }

        return $response;
    }
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $isActive = $this->isMaintenance($request);

        if ($isActive === false) {
            return $handler->handle($request);
        } else {
            return $this->execute($handler, $request);
        }
    }
    private function execute(RequestHandlerInterface $handler, ServerRequestInterface $request): ResponseInterface
    {        
        $contentType = $this->getConfig('contentType');
        $statusCode = $this->getConfig('statusCode');
        $templateName = $this->getConfig('ctpFileName');
        $templatePath = $this->getConfig('templatePath');
        
        $builder = new ViewBuilder();
            
        $view = $builder
            ->setClassName('Cake\View\View')
            ->setTemplatePath(Inflector::camelize($templatePath))
            ->disableAutoLayout()
            ->build([], $request);
        
        $bodyString = $view->render($templateName);
        
        $response = new Response();
        $response = $response->withType($contentType)
            ->withStatus($statusCode)
            ->withStringBody($bodyString);
        return $response;
    }

    /**
     * @return bool
     * @author gorogoroyasu
     */
    // private function checkStatusFile()
    // {
    //     $path = $this->getConfig('statusFilePath');
    //     if (is_string($path)) {
    //         $path = [$path];
    //     }
    //     foreach($path as $p) {
    //         $fullPath = $p . $this->getConfig('statusFileName');
    //         $ret = file_exists($fullPath);
    //         if ($ret === true) {
    //             return true;
    //         }
    //     }
        
    //     return false;
    // }


    private function isMaintenance($request)
    {
        $ret = $this->getConfig('enabled');
        if ($ret === false) {
            return false;
        }
        $ret = $this->isAllowIp($request);
        if ($ret === true) {
            return false;
        }

        return true;
    }

    private function getMyIpAddress($request)
    {
        $params = $request->getServerParams();

        // X-Forwarded-Forはカンマ区切り。一番近いReverse proxyで付与されたIPを末尾から取得する。
        if ($this->getConfig('useXForwardedFor') && isset($params['HTTP_X_FORWARDED_FOR'])) {
            $ips = explode(',', $params['HTTP_X_FORWARDED_FOR']);
            return trim(array_pop($ips));
        } else {
            return isset($params['REMOTE_ADDR']) ? $params['REMOTE_ADDR'] : null;
        }
    }

    private function isAllowIp($request)
    {
        
        $myIpAddress = $this->getMyIpAddress($request);
        if (is_null($myIpAddress)) {
            return false;
        }

        $ipAddressList = $this->getConfig('allowIp');
        if (empty($ipAddressList)) {
            return false;
        }

        foreach ($ipAddressList as $allowIP) {
            // サブネットマスクが指定されていない場合 /32 を追加
            if (strpos($allowIP, '/') == 0) {
                $allowIP .= '/32';
            }
            // IPアドレスの書式チェック
            if (!preg_match('/^(([1-9]?[0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]).){3}([1-9]?[0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\/([1-9]|1[0-9]|2[0-9]|3[0-2])$/', $allowIP)) {
                // 書式が不正
                continue;
            }
            list($ip, $maskBit) = explode("/", $allowIP);
            $ipLong = ip2long($ip) >> (32 - $maskBit);

            $selfIpLong = ip2long($myIpAddress) >> (32 - $maskBit);
            if ($selfIpLong === $ipLong) {
                return true;
            }
        }

        return false;
    }
}
~~~


とりあえず無事にメンテナンス画面が表示されたので良かったです。
定数isMaintenanceでメンテナンス中か判断できるので、切り戻し防止にレイアウト追加も出来るので良かったです。