[ECCUBE4] 特定の条件のときだけcontrollerを切り替える

EC-CUBE4にて特定の条件下では標準のcontrollerを乗っ取って別のcontrollerに処理を渡したいときのTipsです

なお、”特定の条件下”ではなくていつでも乗っ取りたいのなら、ルーティングそのものを上書きしてしまう方法が取れます

EC-CUBE既存のルーティングを上書きするには、同じパスと名前でルーティングを定義します。

EC-CUBE既存のルーティングを上書きする

Symfony Eventを使う

controllerの切り替えはSymfonyのeventの一つ、kernel.controllerでできるとのこと
ということでkernel.controllerのevent listnerを作成して、その中で”特定の条件になっているかどうか”を判断し、条件が満たされている場合はcontrollerを置き換える、という処理を実装しました

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\FilterControllerEvent;
use Symfony\Component\HttpKernel\KernelEvents;

class HogeListener implements EventSubscriberInterface
{
    
    public function onKernelController(FilterControllerEvent $event)
    {
        // 置き換え元のルーティング名
        $fromRoute = 'hoge_route';
        
        // 置き換え先のルーティング名
        $toRoute = 'fuga_route';
        
        $request =  $event->getRequest();
        $curRoute = $request->get('_route');
        
        if ($fromRoute !== $curRoute)
        {
            return;
        }
        
        if( 【特定の条件になっていない】 )
        {
            return;
        }
        
        $params = $request->query->all();
        $controller =  $event->getController()[0];
        $response = $controller->forwardToRoute($toRoute, [], $params);
        $event->setController(function() use ($response) {
            return $response;
        });
    }
    
    
    public static function getSubscribedEvents()
    {
        return [
            KernelEvents::CONTROLLER => 'onKernelController',
        ];
    }
}

ほぼほぼSymfonyの機能だけで実装できました
ただ一点forwardToRouteはEC-CUBEの実装かと思うので、もし素のSymfonyで行う際はここは置き換える必要があるかと思います

[Kindle] Oasis 第10世代を買いました

今年のAmazonプライムデーにKindle Oasis (第10世代)を衝動買いしたのが先程届きました

パッケージから取り出したところ

充電は相変わらずmicro USBなんだね
贅沢言えばtype-Cになってて欲しかったなー
そういえばACアダプタは付いてこなかったです
今どき持ってない人なんかいないだろうしゴミが増えないと思えば好印象
いっそケーブルも無くしてしまっていいのかもね

ライブラリに並んでる本に趣味が出ている笑

隣に写っているのは今まで使っていたKindle(2013年7月購入)
Paperwhiteの第1世代です
世代も違うしOasisは上位クラスだしで、持ちやすさ軽さ速さ、全てがアップグレードされました
ページめくりが物理ボタンでできるのはほんと快適

そしてなにより一番期待しているのが防水機能
これでお風呂で本が読めるー!

[mac] Chrome Remote DesktopがつながらないのはESETの設定でした

久しぶりにChrome Remote Desktopを使おうとしたら相手先に接続できませんでした
「リモートのパソコンにアクセスできません」と表示されます

chrome remote desktopのエラー

前に接続できたときからいろいろ環境変わってるからなあ・・・
そういえばChromeアプリ版が廃止されてオンライン版になってから初めての利用な気がします
そのせいかな?

ということで原因を調べてみました
ネットワークの設定が原因と言ってますが、別のPC(chromebook)からは問題なく繋がるのでルーターはシロです
そうなるとmac内のファイアウォールが怪しい

ESET Cyber Security Pro

結論から言うと犯人(?)は導入しているウイルス対策ソフト「ESET Cyber Security Pro」でした

設定>パーソナルファイアウォールから
設定を選んで
ファイアウォールの設定画面

ここでフィルタリングモードを対話モードにしてそれっぽい通信を探してみた結果が、その下にあるChrome Helperの通信です
こちらを許可することでRemote Desktopも無事接続できるようになりました

今回設定した内容は以下のとおりです
パスにバージョンナンバーがあるからchromeをバージョンアップしたらまた設定やりなおし・・・?
バージョンナンバーはCurrentに設定しても大丈夫でした!
これならchromeのバージョンアップにも追随してくれそう

アプリケーション/Applications/Google Chrome.app/Contents/Frameworks/Google Chrome Framework.framework/Versions/Current/Helpers/Google Chrome Helper.app
アクション許可
方向両方
プロトコルTCP & UDP
ポートリモート
リモートポートすべて
宛先インターネット全体

[twig] twigで再帰呼び出し

twigで再帰呼び出しができると知りました
そのような処理が必要なときはできるだけPHPで前処理してテンプレートに渡すようにしてましたが、twigで処理できるならそのほうがスマートな場合も多そうです

きっかけはECCUBE4のソース
カテゴリを表示している場所で再帰呼び出しをしています

src/Eccube/Resource/template/default/Block/category_nav_pc.twig

{% set Categories = repository('Eccube\\Entity\\Category').getList() %}

{% macro tree(Category) %}
    {% from _self import tree %}
    <a href="{{ url('product_list') }}?category_id={{ Category.id }}">
        {{ Category.name }}
    </a>
    {% if Category.children|length > 0 %}
        <ul>
            {% for ChildCategory in Category.children %}
                <li>
                    {{ tree(ChildCategory) }}
                </li>
            {% endfor %}
        </ul>
    {% endif %}
{% endmacro %}

{# @see https://github.com/bolt/bolt/pull/2388 #}
{% from _self import tree %}

<div class="ec-categoryNaviRole">
    <div class="ec-itemNav">
        <ul class="ec-itemNav__nav">
            {% for Category in Categories %}
                <li>
                    {{ tree(Category) }}
                </li>
            {% endfor %}
        </ul>
    </div>
</div>

上記のソースでは前半部分でmacroを定義して、後半でそれを一回呼び出してます
呼びされたmacroの中でさらにまた自分自身を呼び出すことで、カテゴリの深さのぶんだけ再帰的に処理されるようになっています

[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のアイコンが出ているのが目印です
問題なくログインできるか試してみてください

[PHP] calcurate UPC-E check digit

最近バーコード絡みの案件があるのでちょくちょくバーコードネタが出てきます
今日はUPC-E(8桁短縮タイプ)のチェックデジットを計算する関数を作ってみました

そんなライブラリどこかに転がってるだろ、と思っていたのですが、これが意外に見つからない・・・
UPC-A対応ならあるのですが、8桁短縮コードは考慮されていない事が多かったです
探し方が悪かったのかな?
とにかく、見つけられなかった以上、自作します

基本的にはJAN(=EAN)であろうとUPCであろうとModulus10/Weight3と呼ばれる方法でチェックデジットは計算されています
これはざっくり言うと、奇数桁の合計×3 + 偶数桁の合計 を10で割った余りを10から引いたもの、です

日本語で書くよりソースコードのほうがわかりやすそうです

function calc_checkdigit($code)
{
    if( !ctype_digit($code) ) return false;
    $code = (string)$code;
    $odd_total  = 0;
    $even_total = 0;
    
    for($i=0; $i<strlen($code); $i++)
    {
        if((($i+1)%2) == 0) {
            // 偶数行合計
            $even_total += $code[$i];
        } else {
            // 奇数行合計
            $odd_total += $code[$i];
        }
    }
 
    // 奇数桁の合計×3 + 偶数桁の合計
    $sum = (3*$odd_total) + $even_total;
    // 10で割った余りを求める
    $remainder = $sum%10;
    // 求められた余りを10から引いた値がチェックデジット(10の場合は0)
    return( (10-$remainder)%10 );
}

で、問題はここから

UPC-Eの場合は一度UPC-A(12桁の通常タイプ)に変換してから計算するという仕様なのですが、この変換がちょっとややこしい
UPC-Eの8桁のうち先頭の1桁は0で固定されており、また末尾1桁はチェックデジットなので、意味のある文字列は2桁目から7桁目までの6文字になるのですが、その末尾(全体の7桁目)によって変換の方法が変わるという仕様です

まとめた資料がcanonさんにありました
UPC短縮バーコードチェックデジット計算方法

よく見るとそこまで複雑な話ではないのですが、ぱっと見じゃ理解できないですよね…

PHPで処理を書くとこうなります

function upce2upca($code)
{
    if( !ctype_digit($code) ) return false;
    $code = (string)$code;
    $len = strlen($code);
    if( $len != 7) return false;
    
    switch($code[6])
    {
        case '0':
        case '1':
        case '2':
            $ret = substr($code,0,3).$code[6].'0000'.substr($code,3,3);
            break;
        case '3':
            $ret = substr($code,0,4).'00000'.substr($code,4,2);
            break;
        case '4':
            $ret = substr($code,0,5).'00000'.$code[5];
            break;
        default:
            $ret = substr($code,0,6).'0000'.$code[6];
            break;
    }
    return $ret;
}

さきほどのModulus10/Weight3の計算と合わせて使うことでUPC-Eのチェックデジット計算ができます

// チェックデジット計算前の7桁
$raw_upce = '0123456'; 

// UPC-A(チェックデジット加える前の11桁)に変換
$raw_upca = upce2upca($raw_upce );

// チェックデジットを計算
$cd = calc_checkdigit($raw_upca); // 5

// 有効な8桁UPC-Eを作る
$valid_upce = $raw_upce.$cd; // 01234565

[mac] PDFをHTMLにする

上から、PDFをHTMLにしてサイトに掲載してくれという依頼が来ました
見た目はそのまま再現できればそれがベストだそうです
正直、個人としてはその作業の意図がわかりません😓

Google は 2001 年に PDF ファイルのインデックス登録を開始(英語)し、現在では 数億件もの PDF ファイルがインデックスに登録されています。

検索結果における PDF ファイルの取り扱いについてのヒント

PDFのまま載せたってインデックスしてもらえるのに、html化するメリットがなんかあるの?

・・・そう思いながらも、理屈を納得してもらうよりはやってしまったほうが早そうなので、言われたとおりのことをやってみました

pdf2htmlEX

調べてみたら pdf2htmlEXというものがありました

pdf2htmlEX

サンプルを見てみると出来がすごい!

でもブログの最終更新は”Looking for new maintainer
そのメッセージどおりGitHubレポジトリはアーカイブされていました
今はこちらでメンテナンスされているのかな?(バージョンは0.14.6のまま変わってないようだけど…)

ともあれインストールはhomebrewにformulaがあったので簡単でした

$ brew install pdf2htmlex

変換の実験してみます
こちらからサンプリファイルを拝借しました

元PDFファイル: sample-pdf.pdf

変換してみます
–embedの部分はそれぞれ

CCSS
Ffont
Iimage
JJavaScript
Ooutline

を意味していて、大文字だとそれをembed(HTMLに埋め込み。画像などはbase64エンコードで埋め込まれる)、小文字だと外部ファイルにする、という設定です
ひとつのhtmlファイルで完結したい場合なんかは全部embedしてしまえばいいわけですね
もうひとつの–fit-widthは幅を何pxにするか、になります

$ pdf2htmlEX --embed cfijo --fit-width 1024 sample-pdf.pdf sample-pdf.html

出力結果HTML: sample-pdf.html

ソースにいくらかクセはあるけども(サンプルテキストのところなんか文章が細切れにspanで分割されている)、見た目は完全に再現されました!

[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流の書き方がスマートに見えますね

[jQuery UI] ドラッグ操作をスマホにも対応させるTIPS

自分の仕事はサーバサイドが主でUI作りなどはデザイナ任せなことが多いのですが、管理画面などではこちらでUIを作ることもままあります
そんなときに頼りにしているのがBootstrap+jQuery UIなのですが、jQuery UIのドラッグ操作がスマホのそれに対応していないのが難点です
最近は管理画面もスマホやタブレットに対応してほしいという話も多いので、これはなんとかしないといけません

で、そんなときにはjQuery UI Touch Punchが役立ちます

参考
jQuery UIをタッチイベントに対応させる
jQuery【 jQuery UI 】Touch Punch を使用してスマホ対応

ただしこれだけでは、スマホでスクロールさせるつもりでスワイプしていたら意図せずドラッグしてしてしまった・・・みたいな状況も起こるようになります
特にsortableなんかでは問題になりがち
そのためエレメント全体を掴めるようにはしないでhandleを付けておくのがおすすめです

参考
ドラッグ&ドロップで並べ替えるときのドラッグできる部分を指定

以上を組み合わせたサンプルコードです

<html>
<head>
<meta charset="utf-8">
    <title>jQuery UI Touch Punch sample</title>
    <link href="https://use.fontawesome.com/releases/v5.6.1/css/all.css" rel="stylesheet">
    <link rel="stylesheet" href="https://code.jquery.com/ui/1.12.1/themes/base/jquery-ui.css">
<style>
#sortable {
    list-style-type: none;
    padding: 0px;
}
#sortable li {
    margin: 10px 0px;
    padding: 10px;
    border: #999 1px solid;
}
#draggable {
    width: 100%;
    margin: 0px;
    padding: 0px;
    border: #999 1px solid;
}
.handle{
    color: #999;
    padding: 10px;
}
</style>
<script src="https://code.jquery.com/jquery-3.4.1.js"></script>
<script src="https://code.jquery.com/ui/1.12.1/jquery-ui.js"></script>
<script src="./jquery.ui.touch-punch.min.js"></script>
<script>
$(function() {
    $('#sortable').sortable({
        handle: 'i.handle',
        axis: 'y'
    });
    $('#draggable').draggable({
        handle: 'i.handle'
    });
});
</script>
</head>
<body>
 
<ul id="sortable">
    <li><i class="handle fas fa-arrows-alt-v"></i> Item 1</li>
    <li><i class="handle fas fa-arrows-alt-v"></i> Item 2</li>
    <li><i class="handle fas fa-arrows-alt-v"></i> Item 3</li>
    <li><i class="handle fas fa-arrows-alt-v"></i> Item 4</li>
    <li><i class="handle fas fa-arrows-alt-v"></i> Item 5</li>
    <li><i class="handle fas fa-arrows-alt-v"></i> Item 6</li>
    <li><i class="handle fas fa-arrows-alt-v"></i> Item 7</li>
</ul>
 
<div id="draggable">
    <p><i class="handle fas fa-arrows-alt"></i> ドラッグしてね</p>
</div>

</body>
</html>

動作サンプル