岩本隆史の日記帳(アーカイブ)

はてなダイアリーのサービス終了をうけて移行したものです。更新はしません。

Pinto公開に向けて #5 ― Erubisでビュー周りを実装した

あらすじ

いじり始めたばかりのRubyで、Pintoというソーシャルブックマークサービスを作ろうとしています。前回はコントローラ周りを実装しました。今回はErubisを使ってビュー周りを実装したのでその報告です(誰に)。

Webアプリのセキュリティについても少し触れますよ。

表示テスト用のテンプレート

まず、eRubyベースのテンプレートを view/script/top.erb に作成しました。あ、「まず」とかいってますが、テンプレート作成に至るまでには、かなり試行錯誤したんですよ。そのまま書くと文章が納豆スパゲティ状態になるので、分かりやすさ優先のつもりで書きますね。

さて、ファイルの中身はこうです。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
    "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="ja">
<head>
<title>Pinto</title>
</head>
<body>
<p style="color:<%== in_quote @color %>"><%= @greeting %></p>
<%== has_tag @address %>
</body>
</html>

in_quote と has_tag は私の独自拡張です。「<%= @greeting %>」では「&<>」をエスケープ、「<%== in_quote @color %>」ではおまけに「"」もエスケープ、「<%== has_tag @address %>」はエスケープなし、という感じです。

@greeting でも「"」をエスケープして良いのですが、そこをしないのがプログラマとしての矜持というか、単なるへそ曲がりというか。

コントローラからの呼び出し

続いて、コントローラからビューを扱う部分を実装しました(ということにさせて)。

# lib/pinto/controller/top.rb

require 'pinto/view'

module Pinto
  module Controller
    class Top
      def self.run(request)
        param = {
          :greeting => '"<こんにちはRuby>"',
          :color    => 'red',
          :address  => '<address>id:IwamotoTakashi</address>'
        }
        response_body = Pinto::View.render('top', param)

        return [
          200,
          {'Content-Type' => 'text/html; charset=UTF-8'},
          [response_body]
        ]
      end
    end
  end
end

見ての通り、ビューオブジェクトにビュー名とパラメータを渡して render しているだけですね。

ビューオブジェクト

ビューオブジェクトはこんな感じです。

# lib/pinto/view.rb

require 'pinto/view/xhtml'

module Pinto
  class View
    def self.render(view, param = {})
      template = File.read("../lib/pinto/view/script/#{view}.erb")
      return Pinto::View::Engine::XHTML.new(template).evaluate(param)
    end
  end
end

テンプレートファイルを読み込んで、XHTML用テンプレートエンジンのインスタンスを作り、evaluateしています。

XHTML用テンプレートエンジン

XHTML用テンプレートエンジンは Erubis::EscapedEruby を継承させました。

# lib/pinto/view/engine/xhtml.rb

require 'erubis'
require 'pinto/view/engine/xhtml/context'

module Pinto
  class View
    class Engine
      class XHTML < Erubis::EscapedEruby
        def escaped_expr(code)
          return "h(#{code})"
        end

        def evaluate(param)
          super Pinto::View::Engine::XHTML::Context.new(param)
        end
      end
    end
  end
end

escaped_expr はエスケープ用のメソッドで、Erubis::EscapedEruby#escaped_expr をオーバーライドしています。こいつのおかげで「<%= @greeting %>」が「h( @greeting )」に置換されます。この h は、Pinto::View::Engine::XHTML::Context#h を指します。

evaluate もオーバーライドです。独自のコンテキストオブジェクトを渡すためですが、それはテンプレートで in_quote と has_tag を使いたいからです。

コンテキストオブジェクト

独自のコンテキストオブジェクトはこんな感じです。

# lib/pinto/view/engine/xhtml/context.rb

require 'erubis/context'
require 'pinto/encoding/utf8'

module Pinto
  class View
    class Engine
      class XHTML < Erubis::EscapedEruby
        class Context < Erubis::Context
          ESCAPE_TABLE = {
            '&' => '&amp;',
            '<' => '&lt;',
            '>' => '&gt;',
            '"' => '&quot;'
          }

          def escape_for_value(value)
            escape_chars = ['&', '<', '>']
            return eliminate(value, escape_chars)
          end

          def escape_for_quote(value)
            escape_chars = ['&', '<', '>', '"']
            return eliminate(value, escape_chars)
          end

          def escape_for_tag(value)
            return eliminate(value)
          end

          alias h        escape_for_value
          alias in_quote escape_for_quote
          alias has_tag  escape_for_tag

          def eliminate(value, escape_chars = nil)
            str = value.to_s
            raise ArgumentError unless Pinto::Encoding::UTF8.valid? str

            control_codes = /
               [\x00-\x08]|\x0B|\x0C|[\x0E-\x1F]  # C0 control codes
              |\x7F                               # DEL
              |\xC2[\x80-\x9F]                    # C1 control codes
            /ux
            str.gsub!(control_codes, '')

            return str unless escape_chars.is_a? Array
            return str.gsub(/[#{escape_chars.join}]/) {|s| ESCAPE_TABLE[s]}
          end
        end
      end
    end
  end
end

各種エスケープメソッドですね。土台となる eliminate メソッドでは下記の処理をおこなっています。

  1. UTF-8として不正であればエラーを投げる
  2. 各種制御文字を削除する
  3. 指定の文字をエスケープする

1はセキュリティを考慮しています。(X)HTML内に不正シーケンスが含まれると脆弱性の原因となりえるので。

2はXMLの仕様を考慮しています。XML 1.0では、C0制御文字集合(#x00〜#x1F)のうち、#x09(HT、いわゆるタブ文字)、#x0A(LF)、#x0D(CR)以外はすべて使用不可と決められているので、それを削除しているわけです。\x7F(DEL)および C1制御文字集合(\xC2\x80〜\xC2\x9F)は使用可能なのですが、XHTMLに残しておく意味はないので、合わせて削除しています。

UTF-8の不正シーケンスチェック

UTF-8の不正シーケンスチェックは下記のようにおこなっています。

# lib/pinto/encoding/utf8.rb

module Pinto
  module Encoding
    class UTF8
      def self.valid?(value)
        valid_utf8 = /^(?:
           [\x00-\x7F]                    # U+0000   - U+007F
          |[\xC2-\xDF][\x80-\xBF]         # U+0080   - U+07FF
          |\xE0[\xA0-\xBF][\x80-\xBF]     # U+0800   - U+0FFF
          |[\xE1-\xEC][\x80-\xBF]{2}      # U+1000   - U+CFFF
          |\xED[\x80-\x9F][\x80-\xBF]     # U+D000   - U+D7FF
          |\xEF[\x80-\xBF][\x80-\xBD]     # U+E000   - U+FFFD
          |\xF0[\x90-\xBF][\x80-\xBF]{2}  # U+10000  - U+3FFFF
          |[\xF1-\xF3][\x80-\xBF]{3}      # U+40000  - U+FFFFF
          |\xF4[\x80-\x8F][\x80-\xBF]{2}  # U+100000 - U+10FFFF
        )*$/x
        return !value.to_s.match(valid_utf8).nil?
      end
    end
  end
end

弾さんのコードそのままですね。

次のアクション

  • トップページをちゃんと書く(多言語を考慮、やっぱり gettext かな)

最後までお読みいただきありがとうございました。