ここでは、HTML によるフォーム入力で、添付ファイルを処理する方法を示す。 ここで説明する方法は CGI の仕組みに即した一例に過ぎないので、 紹介する処理内容の細部には立ち入らず、まずは最後に提示した cgilib2-sh を組み込んで使用する表面的な方法だけ理解し、 より込み入った機能を実装したくなったときに細部の検討をするために 読み直すという立場で構わない。
まず HTML フォーム入力でファイルを送信して CGI スクリプトで受け取るときにやりとりするデータの仕組みを見よう。CGI でファイル送信を行なうには form 要素の属性指定に enctype="multipart/form-data" を追加する。実際に動かしてみてデータの流れを確認する。CGI スクリプトを設置する予定のディレクトリに移動してから
mkdir -m 1777 tmp
としてhttpdプロセスに書き込みできる tmp/ を作成してから実行してみる。
「有効期限つきタグ」に記したように、この tmp/ ディレクトリ内のファイルも httpd に取得されない設定をしておくのが望ましい。
#!/bin/sh
title=ファイル投稿
tmp=${TMPDIR:-tmp}
cat<<EOF
Content-type: text/html; charset=utf-8
<!DOCTYPE html>
<html lang="ja">
<head><title>$title</title></head>
<body>
<h1>$title</h1>
<form action="filepost0.cgi" method="POST" enctype="multipart/form-data">
<label>ファイルを添付してください:
<input type="file" name="image"></label><br>
一言:<br>
<textarea name="hitokoto" rows="5" cols="40"></textarea>
<input type="submit" value="POST">
<input type="reset" value="Reset">
</form>
EOF
if [ -n "$CONTENT_LENGTH" ]; then
ofile=$tmp/filepost-stream
head -c $CONTENT_LENGTH > $ofile
echo "<p>CONTENT_TYPE=$CONTENT_TYPE</p>"
echo "<p>$ofile にPOST結果を出力しました。</p>"
fi
cat<<EOF
</body></html>
EOF
このスクリプトのURLを開くと以下のような入力フォームが現れる。
ファイル投稿
ファイル添付の部分には \0
を含むバイナリデータを添付する。とくに手持ちファイルがなければ
以下のようにすることでテストデータファイルを作成できる。
printf "foo\000\001bar" > /tmp/file.bin
文字列 foo と bar の間に文字コード 0x00 と 0x01 の並びを入れたものである。 このファイルを添付データとして先述の入力フォームに入れ、POST ボタンを押すと以下のような出力が得られる。
CONTENT_TYPE=multipart/form-data; boundary=---------------------------8048465413907165462049879955 tmp/filepost-stream にPOST結果を出力しました。
と出るのでコマンドラインから中味を確認する。cat コマンドに 制御文字を見える形にして表示する -v オプションをつけて出力した例を示す。
cat -v tmp/filepost-stream
-----------------------------8048465413907165462049879955^M
Content-Disposition: form-data; name="image"; filename="file.bin"^M
Content-Type: application/octet-stream^M
^M
foo^@^Abar^M
-----------------------------8048465413907165462049879955^M
Content-Disposition: form-data; name="hitokoto"^M
^M
abcde^M
M-cM-^AM-^BM-cM-^AM-^DM-cM-^AM-^FM-cM-^AM-^HM-cM-^AM-^J^M
-----------------------------8048465413907165462049879955--^M
以下の点を確認したい。
すべての値は Multipart 形式で区切られて1つのストリームに入る。
環境変数 CONTENT_TYPE に multipart/form-data 指定と 境界文字列の値が代入されている。
input名は Content-Disposition 行の name="..." に入る。
送信ファイル名は、同 filename="..." に入る。
foo と bar の間には、0x00 と 0x01 がそのまま入っている。
すべての行の行末は CR+LF(改行+復帰)になっている。
enctype="multipart/form-data" に設定すると、すべてのフォームの値は Multipart 形式のストリームに詰め込まれる。このため、このストリームから各 part を切り出して、文字列は文字列として、添付ファイルは添付ファイルとして 取り出すシェル関数を作成する必要がある。
結論から述べるとNULL文字(0x0)を含むデータをシェル変数に代入して 正確に処理することは汎用的なシェルの機能だけではできない。 したがって、Multipart のデータストリームを分解し、 以下のような出力を行なう部分だけ別のスクリプト言語の力を借りる。
Perlで作成した Multipart 分割スクリプトを示し、以後で利用する。
#!/usr/bin/env perl
$sep = "--" . $ARGV[0];
$dir = ($ARGV[1] || "tmp");
($dir =~ /^([^<>\;\&]*)$/) and $dir = $1;
$/ = undef;
@slices = split($sep, <STDIN>);
@rv = ();
shift(@slices);
pop(@slices);
foreach $item (@slices) {
$item =~ s/^\n//;
($header = $item) =~ s/\r\n\r\n.*//s;
($body = $item) =~ s/.*?\r\n\r\n//s;
$body =~ s/\r\n$//;
unless ($header =~ /\bname=([\"']?)(.*?)\1/) {
next;
}
$name = $2;
if ($header =~ /filename=(['\"]?)(.*?)\1/ && $2 gt "") {
$fn=$2;
if ($fn =~ /^([^\/]*)$/) {
$fn = $1;
}
open(OUT, ">$dir/$fn");
print OUT $body;
close(OUT);
$var = sprintf("%s:filename", $name);
$val = $fn;
} else {
$var = $name;
$val = $body;
}
$var =~ s/([^\w ])/'%' . unpack('H2', $1)/eg; $var =~ tr/ /+/;
$val =~ s/([^\w ])/'%' . unpack('H2', $1)/eg; $val =~ tr/ /+/;
push(@rv, sprintf("%s=%s", $var, $val));
}
print join('&', @rv);
このスクリプトは
mpsplit.pl 境界文字列 ファイル書き出しディレクトリ
のようにして起動する。今回の例の Multipart ストリームに対しては以下のような結果をもたらす。
cat tmp/filepost-stream \
| ./mpsplit.pl ---------------------------8048465413907165462049879955 tmp
image%3afilename=file%2ebinhitokoto=abcde%0d%0a%e3%81%82%e3%81%84%e3%81%86%e3%81%88%e3%81%8a%
出力結果の仕様は以下のように定めた。
「:filename」の部分の : はパーセントエンコードを経ると %3a に変換されるため「%3afilename」となる。
フォームの送信方法が Multipart の場合も、 そうでない場合にもシェルスクリプトで値を取得できるようにしよう。
「フォームから得た値を保存するスキーマ」では、HTML フォームからの値を後の処理で利用するためのテーブルを作成した。 これを拡張し、値が文字列の場合だけでなく、 ファイル送信された場合のファイル名を格納できるようにする。 以下のようなスキーマとする。
カラム | 意味 |
---|---|
tag | タグ |
name | input名 |
val | 文字列の場合はその値、送信ファイルの場合はその中味 |
filename | ファイルが送信された場合はファイル名、それ以外はNULL |
環境変数 CONTENT_TYPE の値が設定されている場合には、 mpsplit.pl を用いて値を取得し、それを cgipars テーブルに格納する。
「バイナリデータのデータベース入出力」で示したように、X'...' 表記 を用いてデータベース入出力を行なう。リスト「hexize/unhexize関数」 で定義したシェル関数を利用する。
フォーム送信されたものが Multipart 形式であれば mpsplit.pl を介してパラメータを受け取り、 そうでなければそのままパラメータを受け取る。このように書き変えた CGI 用シェルスクリプトライブラリ cgilib2 を以下に示し、主要部分について追って説明する。
#!/usr/bin/head -5
# -*- mode: shell-script -*-
# CGI Library v2 for Shell Script
# Use this by source'ing.
# . ./cgilib2-sh
PATH=/usr/local/sqlite3/bin:$PATH
cd `dirname $0` # カレントディレクトリを合わせる
_db=${DB:-db/cgi.sq3} # 変数DBで変更可能にしておく
query() {
sqlite3 -cmd '.timeout 3000' -cmd 'PRAGMA foreign_keys=on' $_db "$@"
}
#【1】HTML実体参照への変換
escape() { # HTMLエスケープ
printf "%s" "$@" |
sed -e '; s/\&/\&/g' -e 's/"/\"/g' -e "s/'/\'/g" \
-e "s/</\</g; s/>/\>/g"
}
_tag=`date +%s`.$$ # EPOCH秒とPID値の合成
_exp='+1 hours' # 1時間後
#【2】一時ディレクトリの作成
tmpd=`TMPDIR=${TMPDIR:-tmp} mktemp -d -t cgi.$_tag.XXXXXX` || exit 1
cleandir() {
rm -r $tmpd
}
trap cleandir EXIT INT TERM HUP
hexize() { # バイナリデータのデータベース入出力での説明参照
perl -ne 'print unpack("H*", $_);'
}
unhexize() {
perl -n -e 's/([0-9a-f]{2})/print chr hex $1/gie'
}
pdecode() { # パーセントエンコードから戻す
tr '%+' '= '| nkf -Ww -mQ
}
storeparam() (
IFS='&' # サブシェル化された関数なのでIFSを保存せず変更
{ cat<<EOF # この出力は { } グループを出たあとの query() に渡る
BEGIN;
CREATE TABLE IF NOT EXISTS tags(id text PRIMARY KEY, expire TEXT);
CREATE TABLE IF NOT EXISTS cgipars(
tag, name text, val text, filename, /* ファイル名用カラムを追加 */
FOREIGN KEY(tag) REFERENCES tags(id) ON DELETE CASCADE
);
INSERT INTO tags VALUES('$_tag', '$_exp');
DELETE FROM tags WHERE expire < '$_now';
EOF
for unit in $1; do # & で文字列を分割して unit に代入して繰り返す
n=${unit%%=*} # 入力名=値 の「入力名」を取り出す
v=${unit#*=} # 入力名=値 の「値」を取り出す
# SQLでは文字列中のシングルクォート(')は2つ重ねてエスケープ(sed部分)
n=`echo "$n" | pdecode | sed -e "s/'/''/g"`
v=`echo "$v" | pdecode | sed -e "s/'/''/g"`
# 【3】テーブルへのファイル内容の挿入
case "$n" in
*:filename) # 入力名に :filename がある場合(mpsplitによる)
n=${n%:filename} # :filename より前を取り出す
cat<<EOF # hexize関数でエンコードしたものを挿入
REPLACE INTO cgipars VALUES('$_tag', '$n', X'`hexize < $tmpd/$v`', '$v');
EOF
;;
?*) # :filenameがなければ name, val のみの挿入
echo "REPLACE INTO cgipars VALUES('$_tag', '$n', '$v', NULL);"
;;
esac
done
echo "COMMIT;"
} | query
)
getpar() { # 指定したパラメータの値を改行区切りで返す
query<<EOF
SELECT val FROM cgipars WHERE name = '$1' AND tag='$_tag';
EOF
}
contenttype() {
echo "Content-type: ${1:-text/html; charset=utf-8}"
contenttype() {} # 一度出力したら不要になる
}
htmlhead() { # Content-type から HTML body要素開始まで
contenttype; echo
cat<<EOF # $1=タイトル
<!DOCTYPE html>
<html lang="ja"><head><title>$1</title>
<!--【4】CSSファイル読み込み設定 -->
<link type="text/css" rel="stylesheet" href="mycgi.css"></head>
<body${bodyclass:+ class="$bodyclass"}>
<h1>$1</h1>
EOF
}
case "$REQUEST_METHOD" in
get|GET) # GETの場合は環境変数から取得
par="$QUERY_STRING" ;;
post|POST) # POSTなら $CONTENT_LENGTH だけ標準入力を読む
# 【5】Multipartかどうかでの場合分け
stream=$tmpd/stream
head -c $CONTENT_LENGTH > $stream
case $CONTENT_TYPE in
*boundary=*)
boundary=${CONTENT_TYPE#*boundary=}
par=`./mpsplit.pl "$boundary" $tmpd < $stream` ;;
*)
par=`cat $stream` ;;
esac
;;
esac
storeparam "$par" # 受け取ったパラメータをDBに格納
【1】の枠: Webページ向けの出力に < > & " ' そのものを出したいときは実体参照(エンティティ参照)に変換する必要がある。 そのための変換を sed コマンドで行なっている。使用例としては、 データベース内にある既存レコードの修正をする場合に、フォームの 初期値を input 要素に与えておく場合などがある。
【2】の枠: フォームから送信されたストリームや、そこから取り出したファイルを 一時的に保存しておくディレクトリを mktemp コマンドで作成する。 cgilib2 スクリプト中では以下のようにして作成している(主要部抜粋)。
_tag=`date +%s`.$$
cleandir() {
rm -r $tmpd
}
trap cleandir EXIT INT TERM HUP
セキュリティと動作時の排他制御の観点から、作業ファイルを作成する場合は
の2点に注意する。mktemp コマンドでどちらも遂行できる。通常 mktemp コマンドは一時ファイルを作成するが、-d オプションの指定によりディレクトリを作成する。 このときのファイル名のテンプレートのXの連続部分がランダムな文字列に変えられ、 作成ディレクトリのモードも700に設定される。 もし、容量不足などで一時ディレクトリ作成が失敗した場合は「|| exit 1」で 即座にスクリプト起動を停止する。作成した一時ディレクトリはシェル変数 tmpd に代入し以後の処理で利用する。
一時ファイル(ディレクトリ)はスクリプト終了時に消去したい。これを 自動的に行なうために用いているのが内部コマンド trap で、第1引数に指定した処理を、第2引数以後のシグナル捕捉のタイミングで実行する。 この例ではすぐ上で定義した cleandir 関数を、EXIT INT TERM HUP のタイミングで呼ぶ設定をしている。
シグナル指定 | 典型的なタイミング |
---|---|
1 または HUP | 端末が失われたとき |
2 または INT | C-cで止められたとき |
15 または TERM | killコマンド(シグナル無指定)で止められたとき |
EXIT | スクリプトが終了するとき |
【3】の枠: ファイルが送信された場合には、mpsplit により入力名に :filename が付加される。それに対応する値はファイルの内容が保存されたファイル名と なっているので、シェル関数 hexize で16進エンコードしたものをデータベースに 挿入する。
cat<<EOF
REPLACE INTO cgipars VALUES('$_tag', '$n', X'`hexize < $tmpd/$v`', '$v');
EOF
の部分が、{ } のブロックを抜けた「| query」に渡されている。
【4】の枠: 入力フォームの操作をしやすくするためのスタイルシートをのちに設定する。 そのためのCSS読み込み宣言を入れてある。また、body 要素の開始タグを
<body${bodyclass:+ class="$bodyclass"}>
としているが、これはこの関数を実行する時点でのシェル変数 bodyclass に空でない値が代入されているときのみ 「 class="$bodyclass"」を 挿入する(先頭の空白に注意)。
【5】の枠: 環境変数 CONTENT_TYPE に Multipart 境界を示す boundary=... 指定のあるなしで直接 storeparam 関数に入力ストリームを渡すか、 mpsplit 経由で渡すかを決めている。
最も簡単な一言投稿例を示す。 一言を読み込んでテーブルに格納するだけのものを作ってみよう。
cgilib2 を利用して、「一言」を入力するだけの form 文をもつ HTML 出力し、そこに入力された言葉を hitokoto テーブルに格納する CGI スクリプト hitokoto.cgi を作成せよ。hitokoto テーブルのスキーマは以下のとおり。
CREATE TABLE hitokoto(comment TEXT UNIQUE, timestamp);
timestamp カラムには書き込み時のタイムスタンプを SQLite 関数 datetime('now', 'localtime') で入れるものとする。
cgilib2 を使用する流れが分かるようにした作成例を示す。
#!/bin/sh
# cgilib2は以下の2行のように利用する
cd `dirname $0`
. ./cgilib2-sh
# このスクリプト名を myname に
myname=`basename $0`
query "CREATE TABLE IF NOT EXISTS hitokoto(
comment TEXT UNIQUE, timestamp);"
# Content-typeヘッダとタイトルまでを出力
htmlhead "一言ポストCGI"
# 前回起動のform文からの値を受け取りテーブルに格納
cmt=`getpar comment` # input名 comment をシェル変数 cmt に代入
if [ -n "$cmt" ]; then # 何か入力されていれば
cmt=`echo "$cmt"|sed "s/'/''/g"`
query "REPLACE INTO hitokoto
VALUES('$cmt', datetime('now', 'localtime'));"
fi
# form文を出力
cat<<EOF
<form action="$myname" method="POST" enctype="multipart/form-data">
<p>一言: <input name="comment" type="text"></p>
<p><input type="submit" value="送信">
<input type="reset" value="リセット"></p>
</form>
EOF
# 既存の一言集を出力
echo '<table border="1">'
query<<EOF # HTML出力モード+ヘッダ出力モードにすると楽
.mode html
.header on
SELECT timestamp 日時, comment コメント
FROM hitokoto
ORDER BY timestamp DESC;
EOF
echo "</table>"
cgilib2 の利用に関るところを強調表示した。
この CGI スクリプトを実行すると次のような入力フォームが出る。
一言ポストCGI
一言:
何か一言入れて「送信」ボタンを押すと登録される。
一言ポストCGI
一言:
日時 コメント 2016-01-24 18:16:16 なにか一言
コメントは重複が許されず(UNIQUE制約による)、 新着順(ORDER BY timestamp DESC)で出力される。
yuuji@koeki-u.ac.jp