[ECCUBE4] reCAPTCHA v3 を導入する

2019年7月現在、巷では7payの不正利用が話題となっています
ECCUBEも構築次第ではクレジットカードやその他決済方法を登録できるわけで、7payの事例も他山の石とすべきでしょう

不正アクセス対策には、いろんな段階でのいろんな対策がありますが、bot対策もその一つとなります
パスワードリスト攻撃などではbotを使ってログインの試行を繰り返しますので、botだと疑われる場合は(たとえパスワードが正しくても)ログインを拒否する、という施策です

reCAPTCHA v3

そこで登場するのがGoogle様謹製のreCAPTCHA
「私はロボットではありません」というチェックボックスがあって、たまに画像を選ばされたりするアレですね
そのチェックボックスがあるタイプはv2なのですが、v3ではチェックボックはなく、ユーザーの行動からbotか人間かを判別する仕組みになっています

以下はそのreCAPTCHA v3をECCUBE4で利用する手順です
コマンドラインを使用したり、普段のカスタマイズでは触らないようなファイルを変更したりするので、多少ハードルは高めかもしれません

サイトを登録

公式サイトでreCAPTCHAを利用したいサイトの情報を登録します
reCAPTCHAタイプではv3を選びます
登録したドメインは、そのサブドメインも登録対象となります(例: ruco.laを登録しておけばblog.ruco.laも対象に)

登録したらサイトキー(フロント側で使用)とシークレットキー(システム側で使用)が表示されます
これがあとで必要になります

ECCUBEに導入する

この記事を執筆時の最新バージョンEC-CUBE 4.0.2への導入手順です
(バージョンが違うと細部が変わっているかもしれません)

composerを使ってライブラリのインストール

ECCUBE4で使用しているフレームワークSymfony用のreCAPTCHAライブラリをインストールします
ここでは Login Recaptcha Bundle for Symfony 3 を使用します

サーバにログインしてec-cubeディレクトリまで移動した後にcomposerを使ってインストールします

$ composer require syspay/login-recaptcha-bundle

しばらく待っているといろいろメッセージが出てきますが

-  WARNING  google/recaptcha (>=1.1): From github.com/symfony/recipes-contrib:master
The recipe for this package comes from the "contrib" repository, which is open to community contributions.
Review the recipe at https://github.com/symfony/recipes-contrib/tree/master/google/recaptcha/1.1

Do you want to execute this recipe?
[y] Yes
[n] No
[a] Yes for all packages, only for the current installation session
[p] Yes permanently, never ask again for this project
(defaults to n): 

ここはデフォルト(n)で大丈夫でした(ただエンターキーを押せばOK)

ECCUBE システム側の設定

今インストールしたbundle(ライブラリ)をECCUBEで使用できるように登録します

app/config/eccube/bundles.php

return [
    Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
    Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
    Doctrine\Bundle\DoctrineCacheBundle\DoctrineCacheBundle::class => ['all' => true],
    Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
    Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
    Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['all' => true],
    Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle::class => ['all' => true],
    Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
    Symfony\Bundle\SwiftmailerBundle\SwiftmailerBundle::class => ['all' => true],
    Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
    Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true, 'test' => true, 'install' => true],
    Symfony\Bundle\WebServerBundle\WebServerBundle::class => ['dev' => true, 'install' => true],
    Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true, 'install' => true],
    DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
    Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
    SunCat\MobileDetectBundle\MobileDetectBundle::class => ['all' => true],
    Knp\Bundle\PaginatorBundle\KnpPaginatorBundle::class => ['all' => true],
    // 末尾に追加
    LoginRecaptcha\Bundle\LoginRecaptchaBundle::class => ['all' => true],
];

次にログイン認証でこのライブラリを使うように変更します
ここで先程サイト登録して得られたシークレットキーが必要になります

app/config/eccube/packages/security.yaml

security:
    encoders:
        # Our user class and the algorithm we'll use to encode passwords
        # https://symfony.com/doc/current/security.html#c-encoding-the-user-s-password
        Eccube\Entity\Member:
          id: Eccube\Security\Core\Encoder\PasswordEncoder
        Eccube\Entity\Customer:
          id: Eccube\Security\Core\Encoder\PasswordEncoder
    providers:
        # https://symfony.com/doc/current/security.html#b-configuring-how-users-are-loaded
        # In this example, users are stored via Doctrine in the database
        # To see the users at src/App/DataFixtures/ORM/LoadFixtures.php
        # To load users from somewhere else: https://symfony.com/doc/current/security/custom_provider.html
        member_provider:
            id: Eccube\Security\Core\User\MemberProvider
        customer_provider:
            id: Eccube\Security\Core\User\CustomerProvider
    # https://symfony.com/doc/current/security.html#initial-security-yml-setup-authentication
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        admin:
            pattern: '^/%eccube_admin_route%/'
            anonymous: true
            provider: member_provider
            form_login_captcha:
                check_path: admin_login
                login_path: admin_login
                csrf_token_generator: security.csrf.token_manager
                default_target_path: admin_homepage
                username_parameter: 'login_id'
                password_parameter: 'password'
                use_forward: true
                success_handler: eccube.security.success_handler
                failure_handler: eccube.security.failure_handler
                google_recaptcha_secret: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
            logout:
                path: admin_logout
                target: admin_login
        customer:
            pattern: ^/
            anonymous: true
            provider: customer_provider
            remember_me:
                secret: '%kernel.secret%'
                lifetime: 3600
                name: eccube_remember_me
                remember_me_parameter: 'login_memory'
            form_login_captcha:
                check_path: mypage_login
                login_path: mypage_login
                csrf_token_generator: security.csrf.token_manager
                default_target_path: homepage
                username_parameter: 'login_email'
                password_parameter: 'login_pass'
                use_forward: true
                success_handler: eccube.security.success_handler
                failure_handler: eccube.security.failure_handler
                google_recaptcha_secret: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
            logout:
                path: logout
                target: homepage

    access_decision_manager:
        strategy: unanimous
        allow_if_all_abstain: false

form_loginの項目名をform_login_captchaに変更して、その下にgoogle_recaptcha_secretという項目を追加しています
google_recaptcha_secretの値には先程の取得したシークレットキーを記入します(XXXXXXXの箇所)
上記ではこの変更をadmin(管理者ログイン)、customer(お客様ログイン)の両方に施しています
もしどちらか片方だけでいい場合はreCAPTCHAしたいほうだけを変更してください

この状態でログインを試してみます
まだフロント側の対応をしていないのでこの段階ではログインに失敗するのが正解です

無事(という言い方はおかしいけど笑)、ログインに失敗しました!

フロント側の対応

フロント側(twig)を対応させます
今度はサイトキーのほうが必要となります

twig(デザインテンプレート)はデフォルトのものはsrc/Eccube/Resource/template以下にあります
カスタマイズする場合はデフォルトは触らずにapp/templateにコピーしてから行うのが推奨されています

管理者ログイン
app/template/admin/login.twig

{% extends '@admin/login_frame.twig' %}

{% form_theme form '@admin/Form/bootstrap_4_horizontal_layout.html.twig' %}

{% block javascript %}
<script src="https://www.google.com/recaptcha/api.js?render=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"></script>
<script>
grecaptcha.ready(function(){
    $('#form1 button').on('click',function(e){
        e.preventDefault();
        grecaptcha.execute('XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', {action: 'login'}).then(function(token) {
            $('#form1').prepend('<input type="hidden" name="g-recaptcha-response" value="' + token + '">');
            $('#form1').submit();
        });
    });
});
</script>
{% endblock %}

{% block main %}

    <div class="container" style="margin-top: 150px;">
        <div class="row">
            <div class="col-12 col-md-6 offset-md-3 col-lg-4 offset-lg-4">
                <div class="text-center p-5 bg-white">
                    {{ include('@admin/alert.twig') }}
                    <form name="form1" id="form1" method="post" action="{{ path('admin_login') }}">
                        <input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
                        <p><img src="{{ asset('assets/img/logo2.png', 'admin') }}" width="106"></p>
                        <div class="form-group">
                            {{ form_widget(form.login_id, {'id': 'login_id', 'attr': {'placeholder': 'admin.login.login_id', 'autofocus': true}}) }}
                        </div>
                        <div class="form-group">
                            {{ form_widget(form.password, {'attr': {'placeholder': 'admin.login.password'}}) }}
                        </div>
                        {% if error %}
                            <div class="form-group">
                                <span class="text-danger">{{ error.messageKey|trans(error.messageData, 'validators')|nl2br }}</span>
                            </div>
                        {% endif %}
                        <button type="submit" class="btn btn-primary btn-lg btn-block">{{ 'admin.login.login'|trans }}</button>
                        {{ form_rest(form) }}
                    </form>
                </div>
            </div>
            <div class="col-12">
                <p class="text-center mt-3">
                    <small>Copyright © 2000-{{ "now"|date("Y") }} EC-CUBE CO.,LTD. All Rights Reserved.</small>
                </p>
            </div>
        </div>
    </div>

{% endblock %}

MYページログイン
app/template/default/Mypage/login.twig

{% extends 'default_frame.twig' %}

{% set body_class = 'cart_page' %}

{% block javascript %}
<script src="https://www.google.com/recaptcha/api.js?render=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"></script>
<script>
grecaptcha.ready(function(){
    $('#shopping_login button').on('click',function(e){
        e.preventDefault();
        grecaptcha.execute('XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', {action: 'ecommerce'}).then(function(token) {
            $('#shopping_login').prepend('<input type="hidden" name="g-recaptcha-response" value="' + token + '">');
            $('#shopping_login').submit();
        });
    });
});
</script>
{% endblock %}

{% block main %}

    <div class="ec-role">
        <div class="ec-pageHeader">
            <h1>{{ 'common.login'|trans }}</h1>
        </div>
    </div>

    <div class="ec-role">
        <div class="ec-grid3">

            <div class="ec-grid3__cell2">
                <form name="shopping_login" id="shopping_login" method="post" action="{{ url('mypage_login') }}">
                    <input type="hidden" name="_target_path" value="shopping" />
                    <input type="hidden" name="_failure_path" value="shopping_login" />
                    <input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
                    <div class="ec-login">
                        <div class="ec-login__icon">
                            <div class="ec-icon"><img src="{{ asset('assets/icon/user.svg') }}" alt=""></div>
                        </div>
                        <div class="ec-login__input">
                            <div class="ec-input">
                                {{ form_widget(form.login_email, { attr: { 'style' : 'ime-mode: disabled;', placeholder: 'common.mail_address'|trans, 'autofocus': true }}) }}
                                {{ form_widget(form.login_pass, { attr: { placeholder: 'common.password'|trans }}) }}
                            </div>
                            {% if BaseInfo.option_remember_me %}
                            <div class="ec-checkbox">
                                <label>
                                    {% if is_granted('IS_AUTHENTICATED_REMEMBERED') %}
                                        <input type="hidden" name="login_memory" value="1">
                                    {% else %}
                                        {{ form_widget(form.login_memory, { 'label': 'common.remember_me'|trans }) }}
                                    {% endif %}
                                </label>
                            </div>
                            {% endif %}
                        </div>
                        {% if error %}
                            <p class="ec-errorMessage">{{ error.messageKey|trans(error.messageData, 'validators')|nl2br }}</p>
                        {% endif %}
                        <div class="ec-grid2">
                            <div class="ec-grid2__cell">
                                <div class="ec-login__actions">
                                    <button type="submit" class="ec-blockBtn--cancel">{{ 'common.login'|trans}}</button>
                                </div>
                            </div>
                            <div class="ec-grid2__cell">
                                <div class="ec-login__link"><a class="ec-link" href="{{ url('forgot') }}">{{ 'common.forgot_login'|trans}}</a>
                                </div>
                                <div class="ec-login__link"><a class="ec-link" href="{{ url('entry') }}">{{ 'common.signup'|trans}}</a>
                                </div>
                            </div>
                        </div>
                    </div>
                </form>
            </div>

            {% if is_granted('IS_AUTHENTICATED_REMEMBERED') == false %}
                <div class="ec-grid3__cell">
                    <div class="ec-guest">
                        <div class="ec-guest__inner">
                            <p>{{ 'front.shopping.guest_purchase_message'|trans }}</p>
                            <div class="ec-guest__actions"><a class="ec-blockBtn--cancel" href="{{ url('shopping_nonmember') }}">{{ 'front.shopping.guest_purchase'|trans }}</a>
                            </div>
                        </div>
                    </div>
                </div>
            {% endif %}

        </div>
    </div>
{% endblock %}

商品購入ログイン
app/template/default/Shopping/login.twig

{% extends 'default_frame.twig' %}

{% set body_class = 'cart_page' %}

{% block javascript %}
<script src="https://www.google.com/recaptcha/api.js?render=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"></script>
<script>
grecaptcha.ready(function(){
    $('#shopping_login button').on('click',function(e){
        e.preventDefault();
        grecaptcha.execute('XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', {action: 'ecommerce'}).then(function(token) {
            $('#shopping_login').prepend('<input type="hidden" name="g-recaptcha-response" value="' + token + '">');
            $('#shopping_login').submit();
        });
    });
});
</script>
{% endblock %}

{% block main %}

    <div class="ec-role">
        <div class="ec-pageHeader">
            <h1>{{ 'common.login'|trans }}</h1>
        </div>
    </div>

    <div class="ec-role">
        <div class="ec-grid3">

            <div class="ec-grid3__cell2">
                <form name="shopping_login" id="shopping_login" method="post" action="{{ url('mypage_login') }}">
                    <input type="hidden" name="_target_path" value="shopping" />
                    <input type="hidden" name="_failure_path" value="shopping_login" />
                    <input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
                    <div class="ec-login">
                        <div class="ec-login__icon">
                            <div class="ec-icon"><img src="{{ asset('assets/icon/user.svg') }}" alt=""></div>
                        </div>
                        <div class="ec-login__input">
                            <div class="ec-input">
                                {{ form_widget(form.login_email, { attr: { 'style' : 'ime-mode: disabled;', placeholder: 'common.mail_address'|trans, 'autofocus': true }}) }}
                                {{ form_widget(form.login_pass, { attr: { placeholder: 'common.password'|trans }}) }}
                            </div>
                            {% if BaseInfo.option_remember_me %}
                            <div class="ec-checkbox">
                                <label>
                                    {% if is_granted('IS_AUTHENTICATED_REMEMBERED') %}
                                        <input type="hidden" name="login_memory" value="1">
                                    {% else %}
                                        {{ form_widget(form.login_memory, { 'label': 'common.remember_me'|trans }) }}
                                    {% endif %}
                                </label>
                            </div>
                            {% endif %}
                        </div>
                        {% if error %}
                            <p class="ec-errorMessage">{{ error.messageKey|trans(error.messageData, 'validators')|nl2br }}</p>
                        {% endif %}
                        <div class="ec-grid2">
                            <div class="ec-grid2__cell">
                                <div class="ec-login__actions">
                                    <button type="submit" class="ec-blockBtn--cancel">{{ 'common.login'|trans}}</button>
                                </div>
                            </div>
                            <div class="ec-grid2__cell">
                                <div class="ec-login__link"><a class="ec-link" href="{{ url('forgot') }}">{{ 'common.forgot_login'|trans}}</a>
                                </div>
                                <div class="ec-login__link"><a class="ec-link" href="{{ url('entry') }}">{{ 'common.signup'|trans}}</a>
                                </div>
                            </div>
                        </div>
                    </div>
                </form>
            </div>

            {% if is_granted('IS_AUTHENTICATED_REMEMBERED') == false %}
                <div class="ec-grid3__cell">
                    <div class="ec-guest">
                        <div class="ec-guest__inner">
                            <p>{{ 'front.shopping.guest_purchase_message'|trans }}</p>
                            <div class="ec-guest__actions"><a class="ec-blockBtn--cancel" href="{{ url('shopping_nonmember') }}">{{ 'front.shopping.guest_purchase'|trans }}</a>
                            </div>
                        </div>
                    </div>
                </div>
            {% endif %}

        </div>
    </div>
{% endblock %}

長々と全体を掲載していますが、3つどれもjavascriptブロックを追加しただけで、その他の箇所は変更していません
サイトキーを記入する場所はそれぞれ2箇所づつあります(XXXXXXXの箇所)

以上で完了です!
動作確認の前には、念のためキャッシュをクリアしておくのがおすすめです

$ bin/console cache:clear --no-warmup
管理者ログイン
MYページログイン

右下にreCAPTCHAのアイコンが出ているのが目印です
問題なくログインできるか試してみてください

[ECCUBE4] プラグインをストアに申請してみました

先日作った返信付き商品レビュープラグインをEC-CUBE オーナーズストアに申請してみました

申請のために作ったバナー画像

ストアに申請するまでの手順はたった3ステップ

  1. EC-CUBE パートナーに登録する
  2. パートナーページにログインする
  3. プラグイン管理からプラグインの情報を一通り入力して登録する

とても簡単なカスタマイズだったけど、この程度でも申請は通るのでしょうか😓

カスタマイズ自体より、この申請のためにバナー画像作ったり、ECCUBE 4.0.0や4.0.1の環境をわざわざ作って動作検証したりと、そっちのほうが大変だったかも笑

審査結果は2週間を目処にメールで連絡が来るそうです
さてさて、どうなるかな

(2019/07/09 追記)
申請は通りました
https://www.ec-cube.net/products/detail.php?product_id=1925

[ECCUBE4] twigでのasset関数と同じ処理をController上でする

ECCUBE4で、例えば商品画像のURLを取得する場合、デザインテンプレート(twig)上では以下のように指定します

{{ asset('hogehoge.png', 'save_image') }}

// 出力結果
/html/upload/save_image/hogehoge.png

asset関数に個別のファイル名を渡せば’save_image’までのパスも付け足して返してくれるわけですね

これと同じことをContoller上でしたい!
たったこれだけのことにだいぶ調べるハメとなりました

ECCUBE3では image_save_urlpath というのが定義されているので

$app['config']['image_save_urlpath'].'/hogehoge.png'

とでもするだけの話だったのですが、4では探せども探せどもimage_save_urlpathに該当するものが見つからない…
eccube_save_image_dir というものはありましたが、そのurlpath版は無いようなのです
※ この場合、dirのほうはサーバOS上でのファイルのパス、urlpathはWEB上でのドキュメントルートからのパスを指します

結論としては以下のようにして’save_image’まで込みでファイルパスを取得できます

use Eccube\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Asset\Packages;

class HogeController extends AbstractController
{
    /**
     * @var Packages
     */
    protected $packages;

    /**
     * HogeController constructor.
     *
     * @param Packages $packages
     */
    public function __construct(
        Packages $packages
    ) {
        $this->packages = $packages;
    }

    public function index(Request $request)
    {
        $filepath = $this->packages->getUrl('hogehoge.png', 'save_image');
        
        ....
    }
}

Symfony\Component\Asset\PackagesgetUrlで、twigのassetでやっているのと同じ処理ができました

やり方が分かってしまえば、configからパスを取り出すECCUBE3流より、ECCUBE4流の書き方がスマートに見えますね

[ECCUBE4] プラグインに同梱したリソースファイルのコピー

今日もECCUBE4の勉強を進めています

dataTablesを使うプラグインを試作中

プラグインを作った際、JSやCSSのリソースファイルを同梱したいのはよくある話かと思います
プラグインに同梱したリソースファイルを実際に使うには本体側へのコピーが必要になるのですが、ECCUBE3ではそれはプラグインの製作者がプラグインマネージャーにて自前でやっていました

EC-CUBE 3.0 リソースファイル、ブロック

これがECCUBE4ではプラグインインストール時に自動で行われるそうです

リソースコピーの使い方

[プラグインディレクトリ]/Resource/assets

にあるファイルが

[ECCUBEインストールディレクトリ]/html/plugin/[プラグインcode]/assets

にコピーされます

twig内でリソースを使いたい際は以下のようになります

{{ asset('[プラグインcode]/assets/[リソースのパス]', 'plugin') }}

// 例えばHogeHogeプラグインでcss/datatables.min.cssを読み込む場合は
{% block stylesheet %}
<link rel="stylesheet" href="{{ asset('HogeHoge/assets/css/datatables.min.css', 'plugin') }}"/> 
{% endblock %}

参考ページ
4系プラグインのPluginManagerの書き方

[ECCUBE] ECCUBE4のプラグインを学ぶために商品レビュープラグインを改造して返信機能をつけてみる

ECCUBE4学習の3日目です
プラグインサンプルとして提供されている商品レビュープラグインを改造してみようと思います

githubからzipを落としてきて app/Plugin の下に置きます
2019年6月24日の段階では開発ドキュメントにProductReviewの名前で展開するように書かれていますが、実際はProductReview4とします
(そうしなければ次のインストールに失敗します)

$ cd ~/apps/ec-cube/app/Plugin
$ wget https://github.com/EC-CUBE/ProductReview-plugin/archive/4.0.zip
$ unzip 4.0.zip
$ mv ProductReview-plugin-4.0 ProductReview4
$ rm 4.0.zip

落としてきたProductReview4プラグインのインストール&有効化

$ cd ~/apps/ec-cube/
$ bin/console eccube:plugin:install --code=ProductReview4
$ bin/console eccube:plugin:enable --code=ProductReview4
ProductReview4プラグインのインストール完了

基本設定(プラグイン名やバージョン)は composer.json に書く、と。ここで追加のcomposerも書けるのかー
ふむふむふむ…

見てるだけでは頭に入らないので、改造することでもう少し理解を進めてみます
レビューにショップオーナーからの返信機能を付け加えてみます

データベースの定義は・・・
それらしいのは Entity/ProductReview.php しか見つからない。ここに見よう見まねで付け加えてみましょうか
apps/ec-cube/app/Plugin/ProductReview4/Entity/ProductReview.php の末尾に以下のように追加

/**
 * @var string
 *
 * @ORM\Column(name="reply", type="text", nullable=true)
 */
private $reply;

/**
 * Get reply.
 *
 * @return string
 */
public function getReply()
{
    return $this->reply;
}

/**
 * Set reply.
 *
 * @param string $reply
 *
 * @return ProductReview
 */
public function setReply($reply)
{
    $this->reply = $reply;

    return $this;
}

DBに反映されるのはインストール時だと思うので、一度無効化>アンインストールしてから、再度インストール>有効化してみます

$ bin/console eccube:plugin:disable --code=ProductReview4
$ bin/console eccube:plugin:uninstall --code=ProductReview4
$ bin/console eccube:plugin:install --code=ProductReview4
$ bin/console eccube:plugin:enable --code=ProductReview4

mysqlにログインしてテーブル構造確認( show columns from plg_product_review )すると・・・

おお、ちゃんとカラムができてる!
このあともう少し調べてみたら、なにも再インストールをしなくとも
bin/console eccube:plugin:schema-update ProductReview4
だけでDB再定義できることもわかりました

では、お店からの返信を入力できるように管理画面用のFormとtwigを改造します

app/Plugin/ProductReview4/Form/Type/Admin/ProductReviewType.php
こちらのcommentの定義の後ろに追加

~~前略~~
            ->add('comment', TextareaType::class, [
                'constraints' => [
                    new Assert\NotBlank(),
                    new Assert\Length(['max' => $config['eccube_ltext_len']]),
                ],
                'attr' => [
                    'maxlength' => $config['eccube_ltext_len'],
                ],
            ])
            ->add('reply', TextareaType::class, [
                'required' => false,
                'constraints' => [
                    new Assert\Length(['max' => $config['eccube_ltext_len']]),
                ],
                'attr' => [
                    'maxlength' => $config['eccube_ltext_len'],
                ],
            ])
            ;

app/Plugin/ProductReview4/Resource/template/admin/edit.twig
こちらもコメントの後ろに追加

<div class="row mb-2">
    <div class="col-3">
        <span>{{ 'コメント'|trans }}</span>
        <span class="badge badge-primary ml-1">{{ 'product_review.common.required'|trans }}</span>
    </div>
    <div class="col">
        {{ form_widget(form.comment, {'attr': {'rows': '6'}}) }}
        {{ form_errors(form.comment) }}
    </div>
</div>

<div class="row mb-2">
    <div class="col-3">
        <span>{{ '返信'|trans }}</span>
    </div>
    <div class="col">
        {{ form_widget(form.reply, {'attr': {'rows': '6'}}) }}
        {{ form_errors(form.reply) }}
    </div>
</div>
管理画面でお店からの返信を入力できるかの確認

管理画面に返信欄を追加できました

フロント画面にも返信を反映させましょう
app/Plugin/ProductReview4/Resource/template/default/review.twig
前半のCSS部分と後半のHTML部分に追加

~~略~~

<style type="text/css">

    #product_review_area {
        border-top: 1px solid #E8E8E8;
        padding-bottom: 0;
        margin-bottom: 20px;
    }

    #product_review_area .ec-rectHeading {
        cursor: pointer;
        margin-top: 20px;
        margin-bottom: 20px;
    }

    #product_review_area .ec-rectHeading.is_active i {
        transform: rotate(180deg);
    }

    #product_review_area .review_list {
        padding-left: 25px;
    }

    #product_review_area .review_list li {
        margin-bottom: 16px;
    }

    #product_review_area .review_list .review_date {
        font-weight: bold;
    }

    #product_review_area .recommend_average {
        margin-left: 16px;
        color: #DE5D50;
    }

    #product_review_area .review_list .recommend_level {
        margin-left: 16px;
        color: #DE5D50;
    }

    #product_review_area .review_list .recommend_name {
        margin-left: 16px;
    }

    #product_review_area .review_list .review_reply {
        margin-left: 32px;
        margin-top: 12px;
        padding-left: 12px;
        color: #999;
        border-left: 2px solid #999;
    }

</style>

<script>
    $(function() {
        $('#product_review_area').appendTo($('div.ec-layoutRole__main, div.ec-layoutRole__mainWithColumn, div.ec-layoutRole__mainBetweenColumn'));

        $('#product_review_area .ec-rectHeading').on('click', function() {
            $content = $('#reviewContent');
            if ($content.css('display') == 'none') {
                $(this).addClass('is_active');
                $content.addClass('is_active');
                $content.slideDown(300);
            } else {
                $(this).removeClass('is_active');
                $content.removeClass('is_active');
                $content.slideUp(300);
            }
            return false;
        });
    });
</script>

<!--▼レビューエリア-->
<div id="product_review_area">
    <div class="ec-role">
        {% set positive_avg_star = ProductReviewAvg %}
        {% set nagative_avg_star = 5 - positive_avg_star %}

        <div class="ec-rectHeading is_active">
            <h4>{{ 'product_review.front.product_detail.title'|trans }}
                <!--平均の星の数-->
                <span class="recommend_average">{{ stars.stars(positive_avg_star, nagative_avg_star) }}</span>
                <!--レビュー数-->
                <span>({{ ProductReviewCount }})</span>
                <span class="chevron pull-right">
                    <i class="fas fa-angle-up fa-lg"></i>
                </span>
            </h4>
        </div>
        <div id="reviewContent">
            {% if ProductReviews %}
                <ul class="review_list">
                    {% for ProductReview in ProductReviews %}
                        <li>
                            <p class="review_date">
                                <!--投稿日-->
                                {{ ProductReview.create_date|date_day }}

                                <!--投稿者-->
                                <span class="recommend_name">
                                            {% if ProductReview.reviewer_url %}
                                                <a href="{{ ProductReview.reviewer_url }}"
                                                   target="_blank">{{ 'product_review.front.product_detail.name'|trans({ '%name%': ProductReview.reviewer_name }) }}</a>
                                            {% else %}
                                                {{ 'product_review.front.product_detail.name'|trans({ '%name%': ProductReview.reviewer_name }) }}
                                            {% endif %}
                                        </span>

                                <!--星の数-->
                                {% set positive_star = ProductReview.recommend_level %}
                                {% set nagative_star = 5 - positive_star %}
                                <span class="recommend_level">
                                            {{ stars.stars(positive_star, nagative_star) }}
                                        </span>
                            </p>

                            <!--タイトル-->
                            <strong>{{ ProductReview.title }}</strong>

                            <!--レビューコメント-->
                            <p>{{ ProductReview.comment|nl2br }}</p>

                            <!--レビュー返信-->
                            {% if ProductReview.reply %}
                            <div class="review_reply">
                                <span class="text-info">お店からの返信</span>
                                <p>{{ ProductReview.reply|nl2br }}</p>
                            </div>
                            {% endif %}
                        </li>
                    {% endfor %}
                </ul>
            {% else %}
                <p>{{ 'product_review.front.product_detail.no_review'|trans }}</p>
            {% endif %}
        </div>
        <div>
            <a href="{{ url('product_review_index', { id: Product.id }) }}"
               class="ec-inlineBtn--action">{{ 'product_review.front.product_detail.post_review'|trans }}</a>
        </div>
    </div>
</div>
<!-- ▲レビューエリア -->
商品詳細画面でレビューにお店から返信も追加

これでお店からの返信機能が出来上がりました!

ここまでの改造を反映させた返信機能付き商品レビュープラグインを公開しておきます
ProductReview-custom.zip

[ECCUBE] ECCUBE4をカスタマイズしてみる

昨日ECCUBE4のインストールはできたので、今日はカスタマイズの実践をしてみたいと思います
実際の案件でよくある、商品情報に項目を追加したい!をやってみます

Entityのカスタマイズ

ECCUBE3となにより違うのは、app/Customizeというディレクトリがあること
ECCUBE3ではカスタマイズ用のベースプラグインを作ったりしてたけどそれが要らなくなったんですね

開発ドキュメントをそのままなぞる形で商品情報にメーカー名を入れるカスタマイズをします。

app/Customize/Entity/ProductTrait.php

<?php

namespace Customize\Entity;

use Doctrine\ORM\Mapping as ORM;
use Eccube\Annotation\EntityExtension;

/**
  * @EntityExtension("Eccube\Entity\Product")
 */
trait ProductTrait
{
    /**
     * @ORM\Column(type="string", length=255, nullable=true)
     * @Eccube\Annotation\FormAppend(
     *     auto_render=false,
     *     type="\Symfony\Component\Form\Extension\Core\Type\TextType",
     *     options={
     *          "required": false,
     *          "label": "メーカー名"
     *     })
     */
    public $maker_name;
}

Entityを作ったらそこからProxyクラスを生成します
Proxy…ECCUBE3にはなかった用語ですね

$ bin/console eccube:generate:proxies
gen -> /opt/bitnami/apps/ec-cube/app/proxy/entity/Product.php

出来上がった app/proxy/entity/Product.php を見るとECCUBE3時代のEntityファイルのような内容
EC4のEntity ≒ EC3のdoctrine/*dcm.yml
EC4のProxy ≒ EC3のEntity
みたいな理解でいいのかな

Proxyができたらそれをデータベースに反映させます

$ bin/console doctrine:schema:update --dump-sql --force

テンプレートをカスタマイズ

さきほど作ったEntityに@Eccube\Annotation\FormAppendを記述したのでこれでもうフォームを使う準備は整っています
あとは管理画面の商品管理画面にメーカー名の項目を追加するよう、カスタマイズtwigを作成します

src/Eccube/Resource/template/admin/Product/product.twig
をそのまま以下にコピーして
app/template/admin/Product/product.twig

商品説明と販売価格(これはproduct_classがないときだけ出てくる項目)の間にメーカー名フォームを記述

 ~~ 前略 ~~

                    {{ form_widget(form.description_list, { attr : { rows : "4"} }) }}
                    {{ form_errors(form.description_list) }}
                </div>
            </div>
        </div>
    </div>
    
    {# maker_nameフォーム #}
    <div class="row">
        <div class="col-3">
            <div class="d-inline-block">
                <span>{{ form.maker_name.vars.label|trans }}</span>
            </div>
        </div>
        <div class="col mb-2">
            {{ form_widget(form.maker_name) }}
            {{ form_errors(form.maker_name) }}
        </div>
    </div>
    
    {% if has_class == false %}
        <div class="row">
            <div class="col-3">
                <div class="d-inline-block">
                    <span>{{ 'admin.product.sale_price'|trans }}</span>
                    
~~ 後略 ~~

以上で商品情報にメーカー名が追加できました!

[ECCUBE] GCPでECCUBE4事始め

ECCUBE4が正式リリースされたのが2018年10月。今が2019年6月なのでもう半年以上経ちました
まだ実際の案件として話は出てきてないんだけど、そろそろどんなものなのか触っておこうかと思いました

開発ドキュメントをざっとみたところ3.0よりも随分カスタマイズしやすくなってる予感!

GCPでLAMP環境立ち上げ

昔の印象が強いのか、どうしてもECCUBEはMySQLはダメでPostgreSQL推奨な気がしてしまいます
4系がどうなってるのかわからないけども、とりあえずは昔の印象通りLAPP (Linux+Apache+PostgreSQL+PHP) で構築してみます

初出ではLAPPで環境作成していましたが、後ほどカスタマイズする際にdoctrine:schema:updateでエラーを吐くため、LAMP(PostgreSQLではなくMySQL)で環境をつくるように変更しました
昔と違って今のECCUBEはMySQLのほうがよく検証されているのかな

Marketplaceにちょうどいいものがありましたのでこれを採用
LAPP Certified by Bitnami
LAMP Certified by Bitnami

さっそくデプロイ

ECCUBE4のインストールと設定

環境ができあがったら早速SSHログインしてみます
これ、最初からcomposerまで入ってる!
ほんとに手間なくていいなあ
次のステップとして初期パスワードを変えろとか静的IPにしたらとか出てるけど、ECCUBEのお試しができたら捨てるつもりの環境なのでそれらは無視して進めます

なんとなくbitnamiの流儀に従って/opt/bitnami/appsにECCUBEをインストールします

$ sudo chmod 777 /opt/bitnami/apps
$ cd /opt/bitnami/apps
$ sudo composer self-update
$ composer create-project ec-cube/ec-cube ec-cube "4.0.x-dev" --keep-vcs

composerをログインユーザで進めるとcacheを作る権限がないよと警告が出ていますが今は気にしない
暫く待つとインストール完了します

続いてECCUBEの動作設定をします
bitnamiの標準ではMySQLのrootユーザのパスワードはサーバ管理者と同じになっているそうです
管理者パスワードはGCPでデプロイしたときに表示されてるやつですね
本番ならrootユーザで運用なんてしないけど、今回はお試しなので手っ取り早く別ユーザー作らずにrootでいきましょう
データベース名はわかりやすく”eccube”で
Databaseの項目以外はデフォルトのまま設定を進めます

$ cd ec-cube
$ bin/console eccube:install
Database Url [sqlite:///var/eccube.db]:
> mysql://root:<管理者パスワード>@localhost/eccube
Database Server version [auto]:
>
Mailer Url [null://localhost]:
>
Auth Magic [9Vst7fuQkJUTZo8o]:
>

!                                                                                                                      
! [CAUTION] Execute the installation process. All data is initialized.                                                 
!                                                                                                                      

Is it OK? (yes/no) [yes]:
>

開発ドキュメントで書き込み権限が必要と書かれているディレクトリ・ファイルのパーミッションを変更しておきます

$ sudo chmod 777 .
$ sudo chmod 777 app
$ sudo chmod 777 app/Plugin
$ sudo chmod 777 app/PluginData
$ sudo chmod 777 app/proxy
$ sudo chmod 777 app/template
$ sudo chmod 777 html
$ sudo chmod 777 var
$ sudo chmod 777 vendor
$ sudo chmod 666 composer.json
$ sudo chmod 666 composer.lock
$ sudo chmod 666 .env
$ sudo chmod 666 .htaccess

Apacheの設定

ECCUBEの準備もできたのであとはapache設定

$ sudo sed -i -e 's/\/opt\/bitnami\/apache2\/htdocs/\/opt\/bitnami\/apps\/ec-cube/g' /opt/bitnami/apache2/conf/bitnami/bitnami.conf
$ sudo apachectl restart

これで http://<GCPのIPアドレス> でサイトが表示されました!

管理画面は http://<GCPのIPアドレス>/admin ですね
ID: admin PW: password
仮運用とはいえこれは単純すぎるのでログイン後に変更しておかないと

ここまで、なにもないゼロからECCUBE立ち上げまであっという間。
良い世の中になったもんです。

ですが今回インストールが目的じゃないのです。
明日以降カスタマイズ方法を学んでみます。
ECCUBE4をカスタマイズしてみる

[ECCUBE] ログイン後の遷移先をマイページに

ECCUBE 3.0系の話。
標準ではmypage/loginからログインするとトップページに遷移します。
それをマイページに変更したいという要望がありました。

これに対応するにはログイン処理のイベントを拾って云々…になるかと思いきや、意外にもtwigの改造だけで対処できることがわかりました。

Mypage/login.twig

{% if app.session.flashBag.has('eccube.login.target.path') %}
	{% for targetPath in app.session.flashBag.get('eccube.login.target.path') %}
		<input type="hidden" name="_target_path" value="{{ targetPath }}" />
	{% endfor %}
{% endif %}

上記のようになっている部分があります。
_target_pathというパラメータで遷移先を指定できるんですね。

ここでflashBagセッションに遷移先が入っていればそこを_target_pathに指定する処理が書かれていますが、mypage/loginからの一般的な操作だと特に指定がないためにデフォルト=トップページに飛んでいるようです。

そこでflashBagセッションに遷移先指定がない場合、_target_pathにマイページを指定するようにして対応しました。

{% if app.session.flashBag.has('eccube.login.target.path') %}
	{% for targetPath in app.session.flashBag.get('eccube.login.target.path') %}
		<input type="hidden" name="_target_path" value="{{ targetPath }}" />
	{% endfor %}
{% else %}
		<input type="hidden" name="_target_path" value="{{ url('mypage') }}" />
{% endif %}