制御文字が含まれていることで発生した問題の対応策

こんにちは、MERY開発部のhoshinoです。

前回投稿した「FFmpegを使用した動画変換処理で発生した問題の対応策」に引き続き、今回は制御文字が含まれていることで発生した問題の対応策*1紹介する内容は、技術書典6で頒布された「INSIDE MERY」の「6.4 制御文字による問題」と同じ内容です。について紹介します。

MERYでは、記事作成ツールで記事を作成した後、記事作成者が入稿をすることで校閲と編集部によるチェックが行われ、差し戻されて再度入稿が行われる度に記事データは更新されます。更新前の記事がどのような内容であったのかを後から確認できるようにするため、入稿毎にその時点での記事をHTMLファイルとして保存しています。この保存のタイミングでエラーが発生し、入稿処理が完了しない問題が発生しました。

ここでは、ファイルに制御文字が含まれていることで、HTMLファイルを保存できなくなった問題に対応した話を紹介します。

ファイルに制御文字が含まれていることで発生する問題

エラー内容は、ログとしてリスト1のように残っていました。エラー発生箇所を確認してみると、PaperclipでMIME typeが不正のためバリデーションエラーが発生し、HTMLファイルが保存されていませんでした。

Started POST "/api/article/xxxxxx"
Processing by Api::ArticlesController#create as JSON
  Parameters: {
    "html"=>#<ActionDispatch::Http::UploadedFile:0x005623b558c328
      @tempfile=#<Tempfile:/tmp/RackMultipart20171206-13-elf62k.html>,
      @original_filename="articleyyyymmdd-hh-pvjhna.html",
      @content_type="text/html",
      …
  }
  …
Command :: file -b --mime
'/tmp/09ca0044e206d36714484097b2494f5420171206-13-pj2tee.html'
[paperclip] Content Type Spoof: Filename articleyyyymmdd-hh-pvjhna.html
(text/html from Headers, ["text/html"] from Extension),
content type discovered from file command: application/octet-stream.
See documentation to allow this combination.

Paperclipでは、MIME typeを確認するためにfileコマンドを使用しているため、保存前のHTMLファイルをPaperclipを使用せずに保存して、直接fileコマンドで確認してみました。すると、リスト2のようにファイル形式がHTML document textではなく、dataとして出力されました。

# 入稿処理でエラーが発生する記事のHTMLファイル
$ file article.html
/Users/example/article.html: data

# 入稿処理でエラーが発生しない記事のHTMLファイル
$ file article.html
/Users/example/article.html: HTML document text, UTF-8 Unicode text

fileコマンドでファイル形式を正しく確認できず、バリデーションで弾かれてしまうことが原因のため、ファイル自体が壊れていないか内容を確認していると、図1のように<0x03>という気になる文字が入り込んでいました。

図 1 テキストエディタでHTMLファイルを開く

試しにその文字を削除してから、再度fileコマンドでファイル形式を確認すると、正しくHTML document textと出力されるようになりました。このことから、この文字が含まれていると、正しくファイル形式を判別できなくなることがわかりました。

この文字について調べてみると、制御文字であることがわかり、他の制御文字をファイル内に一文字だけ追加して確認してみると、リスト2と同じように正しくファイル形式を確認できなくなることがわかりました。

対応策(制御文字を削除する)

制御文字がファイル内に一文字でも存在すると、fileコマンドでファイル形式を正しく取得できなくなり、Paperclipで設定したバリデーションで弾かれてしまいます。HTMLファイルはデータベースに登録されている記事情報から作成しているため、記事作成中にデータベースに登録される文字列から制御文字を削除すれば問題を解決できます。

制御文字は、ライターが入力した文字列を登録する全ての処理で入り込む可能性があるため、リスト3のように指定した属性から制御文字を一括削除する処理をModuleとして作成し、ライターが入力した文字列を保存する前にbefore_validationで削除するようにしました。制御文字全てを削除するように指定してもよいのですが、MERYでは実際に入力されたことのある制御文字だけを指定して削除するようにしています。未指定の制御文字が入り込んできた時はエラー通知がくるので、CONTROL_CHARACTERSに追加して使用しています。

# Module
module ControlCharacterFilter
  extend ActiveSupport::Concern

  CONTROL_CHARACTERS = {
    NUL: "\c@",
    ETX: "\cc",
    VT: "\ck",
    DLE: "\cp",
    US: "\c_",
    RS: "\c^",
    BS: "\ch",
    FS: "\c\\",
    GS: "\c]"
  }
  CONTROL_CHARACTER_PATTERN = /[#{CONTROL_CHARACTERS.values.join('')}]/

  included do
    def remove_control_characters(attributes = [])
      attributes.each do |attribute|
        if self[attribute].present?
          self[attribute].gsub!(CONTROL_CHARACTER_PATTERN, '')
        end
      end
    end
  end
end

# Model
class Content < ActiveRecord::Base
  include ControlCharacterFilter

  # 制御文字を削除したい属性名のシンボルを配列で渡す
  before_validation -> { remove_control_characters([:title, :description]) }
end

MERYでは、複数のアプリケーションから制御文字がデータベースに登録されてしまう可能性があるため、Moduleを複製してアプリケーション毎に追加する必要がありました。しかし、Moduleを複製してしまうと、削除する制御文字の一元管理ができなくなってしまいます。そのため、Gemとして作成して、アプリケーションからincludeして使用しています。

また、リスト3の他に、制御文字が含まれているか確認する処理、制御文字を削除した文字列を返してくれる処理を含めることで、汎用的に使用できるようにしています。

Faradayで受け取ったレスポンス(XML)から制御文字を削除する

制御文字が問題になるのはファイルに含まれている時だけでなく、Faradayでレスポンスをパースする時にも問題になります。Faradayを使用してレスポンス(XML)を受け取った時に制御文字が一文字でも含まれていると、Faraday::ParsingErrorが発生します。レスポンスのパース処理でエラーが発生するため、パース処理が実行される前に制御文字を削除すれば回避できます。リスト4のようにカスタムミドルウェアを作成して使用することで、bodyから制御文字を削除できます。

レスポンスの形式がXMLでない場合は、リスト4を参考にしてカスタムミドルウェアを作成してみてください。

# Gemfile
gem 'faraday'
gem 'faraday_middleware'
gem 'multi_xml'

# lib/faraday/custom_middleware/parse_xml.rb
module Faraday
  class CustomMiddleware
    class ParseXml < Faraday::Response::Middleware
      CONTROL_CHARACTERS = {
        NUL: "\c@",
        ETX: "\cc",
        VT: "\ck",
        DLE: "\cp",
        US: "\c_",
        RS: "\c^",
        BS: "\ch",
        FS: "\c\\",
        GS: "\c]"
      }
      CONTROL_CHARACTER_PATTERN = /[#{CONTROL_CHARACTERS.values.join('')}]/

      def parse(body)
        body.gsub!(CONTROL_CHARACTER_PATTERN, '')
        MultiXml.parse(body)
      end
    end
    Faraday::Response.register_middleware(parse_xml: ParseXml)
  end
end

# config/application.rb
# どこからでも使用できるように読み込んでおく
require Rails.root.join('lib', 'faraday', 'custom_middleware', 'parse_xml')

# カスタムミドルウェアのparse_xmlをFaradayで使用する
Faraday.new('path/to/xxxxx') do |connection|
  connection.response :parse_xml
  connection.adapter Faraday.default_adapter
end

おわりに

制御文字が含まれていることで発生した問題について、原因と対応策について紹介しました。

制御文字が含まれていることで発生する問題を把握してからは、入力するデータに制御文字が含まれていないか気にするようにしています。入力するデータが手入力されたものや外部サービスから取得したものであれば、制御文字が含まれている可能性があります。そのため、処理内容や入力するデータに一見問題がなくても、エラーが発生する場合は、必ず確認するようにしています。そのおかげで、苦労せずに解決できた問題が何件かあります。

みなさまも似た問題に遭遇して原因がわからない時は、制御文字が含まれていないか確認してみてください。

   [ + ]

1.紹介する内容は、技術書典6で頒布された「INSIDE MERY」の「6.4 制御文字による問題」と同じ内容です。
yuki.hoshino

yuki.hoshino

    関連記事
    MERYの記事作成ツールをNuxt.js × Atomic Designで作り直した話(CSS設計編)
    MERYの記事作成ツールをNuxt.js × Atomic Designで作り直した話(機能編)
    トップへ戻る