mod_mruby でファイルダウンロード数を記録する (更新)

※ 2014-4-7 追記しました。

ModSecurity 2.5 という本に mod_security で特定のファイルが何回ダウンロードされたか記録するトリックが載ってました。

その時はおっ、凄い!と思ったけど、そんなことに ModSecurity を使わなくても、今の僕らには mod_mruby があるじゃない!ということでやってみました。mruby-sqlite3 も使います。

# count_download.rb - count file download
#
# You have to build mod_mruby with mruby-sqlite3.
#
# Run this script with mruby and chown the created DB file to apache user.
# After that, add the following line to httpd.conf:
#   SetOutputFilter   mruby
#   mrubyOutputFilter /var/www/count_download.rb

DB = "/tmp/download_count.sqlite3"
# Count downloads of files with these extensions
EXTS = %w(.zip .pdf)

@db = SQLite3::Database.new(DB)

def create_table
  begin
    @db.execute_batch "DROP TABLE download"
  rescue => e
    puts "Failed to DROP!"
    puts e.to_s
  end
  begin
    @db.execute_batch "CREATE TABLE download \
        (uri TEXT PRIMARY KEY, count INTEGER)"
  rescue => e
    puts "Failed to CREATE TABLE! Die!"
    raise e
  end
end

def increment(uri)
  # http://stackoverflow.com/questions/19337029/insert-if-not-exists-statement-in
-sqlite
  @db.execute_batch "INSERT INTO download(uri, count) SELECT ?, 0 \
      WHERE NOT EXISTS(SELECT 1 FROM download WHERE uri LIKE ?)", uri, uri
  @db.execute_batch "UPDATE download SET count = count + 1 WHERE uri LIKE ?", uri
end
  
if $0 == __FILE__
  print "Initialize #{DB}? (Y/n) "
  case gets.chomp.downcase
  when "", "y"
    create_table
    puts "Successful!"
  else
    puts "Nothing done."
  end
else
  r = Apache::Request.new
  if r.method_number == Apache::M_GET and
      EXTS.include? r.uri[-4..-1] and r.status == 200
    increment r.uri
  end
end

@db.close

こんな感じで sqlite3 ファイルに記録されます。

# echo 'select * from download;' | sqlite3 /tmp/download_count.sqlite3
/hoge.zip|2
/eicar.zip|1

上のスクリプトを使うには、まず mod_mruby に mruby-sqlite3 を組み込まないといけません。mod_mruby の build_config.rb に下の行を追加してビルドし直します:

  conf.gem :git => 'git://github.com/mattn/mruby-sqlite3.git'

それから出来上がった mruby (mod_mruby のソースディレクトリの mruby/bin/mruby) で上のスクリプトを実行します。すると /tmp/download_count.sqlite3 というファイルができるので、apache から書き込めるように権限を直します (chown apache:apache /tmp/download_count.sqlite3 とか)。

あとは httpd.conf に SetOutputFilter と mrubyOutputFilter ディレクティブを書いて (スクリプトのコメント参照) apache リスタート。これでようやくダウンロード数が記録できます。

便利だね!

便利だね、って記録したダウンロードカウントを活用する方法を書いてませんでした。
ダウンロードリンクを表示するページにダウンロード数も表示するようにしてみましょう。
まず、ダウンロード数を text/plain で表示する Apache ハンドラか CGI を用意します。ここでは mod_mruby を使ってハンドラを作りました。

# Add following lines to your httpd.conf:
# <Location /download_count>
#   mrubyHandlerMiddle /var/www/download_count.rb
# </Location>
#
# Access  /download_count?uri=foo.zip  and you'll get an integer value.
  
DB = "/tmp/download_count.sqlite3"

class Hash     
  def self.[](*kvs)
    res = {}   
    0.step kvs.length-1, 2 do |i|
      res[kvs[i]] = kvs[i+1]
    end
    res
  end
end     
        
r = Apache::Request.new
r.content_type = "text/plain"
result = 0

if r.method_number == Apache::M_GET
  args = Hash[*r.args.to_s.split(/[&;]/).map{|kv| kv.split('=')}.flatten]
  @db = SQLite3::Database.new(DB)
  begin
    @db.execute("SELECT count FROM download WHERE uri LIKE ?",
                args['uri']) do |row, fields|
      result = row[0]
    end
  rescue
    # do nothing
  end
  @db.close
end

Apache.rputs result

このスクリプトのコメントにある通りに Apache を設定してから /download_count?uri=/hoge.zip などという uri をリクエストするとダウンロード数が表示されます。なので、ダウンロード数を表示したいページに SSI でこの URI を埋め込めば OK です。

ダウンロードページの例です:

<!doctype html>
<!--#config errmsg="[値が取得できません…。]" -->
<html>
  <head>
    <title>ダウンロード</title>
    <style type="text/css">
      dt       { clear: both; width: 8em }
      dt, dd   { float: left }
      dd       { margin-left: 0 }
      em.count { font-weight: bold; font-style: normal }
    </style>
  </head>
  <body>
    <dl>
      <dt>
        <a href="/hoge.zip">hoge.zip</a>
      </dt>
      <dd>
      ダウンロード数: <em class="count"><!--#include virtual="/download_count?uri=/hoge.zip" --></em>
      </dd>
      <dt>
        <a href="/eicar.zip">eicar.zip</a>
      </dt>
      <dd>
      ダウンロード数: <em class="count"><!--#include virtual="/download_count?uri=/eicar.zip" --></em>
      </dd>
    </dl>
    <div style="clear: both"></div>
  </body>
</html>

Web ブラウザでアクセスするとこんな風に見えます。
mod_mruby dl counter

(コウヅ)

mod_mruby で広告を挿入しよう

mod_mruby に M_POST とかの定数が追加されてる!mod_mruby 作者の matsumoto さんありがとう!

せっかく mod_mruby をインストールしたんだから、「この Apache には mod_mruby が入ってるんだぜ?」ということをちょっとアピールしましょう。

# insert_ad.rb - Append ads to response bodies.
#
# Install mod_mruby and add a following line (modify the file path):
#   SetOutputFilter   mruby
#   mrubyOutputFilter /var/www/insert_ad.rb

AD = "mod_mruby kicks ass!"

r = Apache::Request.new
f = Apache::Filter.new

if r.content_type[0,9] == "text/html"
  data = f.flatten.split(/<\/body>/i).first
  f.cleanup

  f.insert_tail data.to_s
  f.insert_tail("\n<!-- Inserted by mod_mruby -->\n" +
      "<hr>\n" +
      "<em style=\"font-weight: bold; font-size: 120%\">#{AD}</em>\n" +
      "</body>\n" +
      "</html>")

  f.insert_eos

elsif r.content_type[0,10] == "text/plain"
  data = f.flatten
  f.cleanup
  f.insert_tail data
  f.insert_tail "\n----------\n#{AD}"
  f.insert_eos
end

こんな感じでレスポンスボディにメッセージ (mod_mruby kicks ass!) が追加されます:
mod_mruby kicks ass

ユーザーエージェントが IE だったら Firefox のダウンロードリンクをはっつけちゃうのも良さそうです。

(コウヅ)

mod_mruby でインプットフィルター!

mruby という組み込み向けの ruby があります。mruby で apache モジュールを書いちゃうおうよ、というのが mod_mruby です。

ということで、POST なリクエストのヘッダとボディをファイルに書き出すインプットフィルターを書いてみました。

# Log headers and a body of POST requests.
# You have to install mod_mruby first.
#
# Add a following line to httpd.conf:
#   mrubyInsertFilterFirst /var/www/request_dumper.rb

LOG_FILE = "/tmp/request.txt"
MAX_LEN = 1024
IN_ONE_LINE = false

M_POST = 2

def format_time(time)
  sprintf("%04d-%02d-%02d %02d:%02d:%02d",
          time.year, time.month, time.day,
          time.hour, time.min, time.sec
         )
end

r = Apache::Request.new

if r.method_number == M_POST
  File.open(LOG_FILE, "a") do |fh|
    body = r.body.to_s[0...MAX_LEN]
    if r.headers_in["Content-Type"].to_s[0,9] == "multipart"
      body = body.inspect
    end
    headers_str = r.headers_in.all.map{|k, v| "#{k}: #{v}"}.join("\n")
    record = ["[#{format_time(Time.now)}]",
      "#{r.the_request}\n#{headers_str}", "#{body}"]
    if IN_ONE_LINE
      record[1] = record[1].inspect
      fh.write(record.join(' ') + "\n")
    else
      fh.write('='*80 + "\n" + record.join("\n\n") + "\n")
    end
  end
end

こんな感じでログが書き込まれます:

================================================================================
[2014-04-04 15:18:33]

POST /post HTTP/1.1
Host: 192.168.3.32
Accept-Language: ja,en-us;q=0.7,en;q=0.3
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.8; rv:28.0) Gecko/20100101 Firefox/28.0
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded
Content-Length: 68
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Referer: http://192.168.3.32/post
Accept-Encoding: gzip, deflate

text=%E3%83%86%E3%82%B9%E3%83%88%E3%81%A7%E3%81%99%E3%82%88%EF%BC%81
================================================================================
[2014-04-04 15:19:17]

POST /post HTTP/1.1
Host: 192.168.3.32
Accept-Language: ja,en-us;q=0.7,en;q=0.3
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.8; rv:28.0) Gecko/20100101 Firefox/28.0
Connection: keep-alive
Content-Type: multipart/form-data; boundary=---------------------------150323039317955918432071897657
Content-Length: 2008267
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Referer: http://192.168.3.32/post
Accept-Encoding: gzip, deflate

"-----------------------------150323039317955918432071897657\r\nContent-Disposition: form-data; name=\"file\"; filename=\"HigherOrderPerl.pdf\"\r\nContent-Type: application/pdf\r\n\r\n%PDF-1.6\r%\342\343\317\323\r\n1914 0 obj\r<</Linearized 1/L 2008022/O 1916/E 113329/N 592/T 1969698/H [ 696 7017]>>\rendobj\r       \rxref\r1914 20\r0000000016 00000 n\r\n0000007713 00000 n\r\n0000007802 00000 n\r\n0000008060 00000 n\r\n0000008186 00000 n\r\n0000009369 00000 n\r\n0000010546 00000 n\r\n0000011730 00000 n\r\n0000011809 00000 n\r\n0000012020 00000 n\r\n0000012232 00000 n\r\n0000013892 00000 n\r\n0000015068 00000 n\r\n0000015277 00000 n\r\n0000015450 00000 n\r\n0000039530 00000 n\r\n0000063166 00000 n\r\n0000087969 00000 n\r\n0000113108 00000 n\r\n0000000696 00000 n\r\ntrailer\r<</Size 1934/Prev 1969687/Root 1915 0 R/Info 1913 0 R/ID[<CF4992AF0C9F828AE8D069D397CF7C52><D0928505241A4E14B4780974C7AF4F2E>]>>\rstartxref\r0\r%%EOF\r           \r1933 0 obj\r<</Length 6921/Filter/FlateDecode/I 19519/L 19503/S 19330>>stream\r\nx\332\354\\{\\23g\272\236\311\21520!22\b3020,\204\213\301Z\eM\264\251\2056@\300\304[0322D27\334x)\v\326v\241\202k\267\356i02B\34335\251(Z\365\204E\252\326j\265\325\266k\273\27706\260T"

簡単に書けちゃいました。同等の apache モジュールを書くのは大変ですよ!Web アプリケーションのデバッグとか、セキュリティチェックとかで使えそうですね、mod_mruby ちゃん!

(コウヅ)