日程調整システムの自動化(HTTP経由)

Webインタフェースからのデータ操作を作成する。 既にCGIプログラムの稼動に必要な設定は済ませてあるものとする。 設定値は以下のとおりとする。

以上の条件で、文字列のみを処理するCGIシステムの設計手順について説明する。 文字列のみでデータ中にNULL文字(ASCIIコード00のもの)を含まないものであれば、 シェルスクリプトで処理を書ける。

ここでは、CGI からのデータの流れを処理するために重要なコマンドの説明をしたのちに、 簡単なCGIスクリプトを作成を試みる。

CGI処理を補佐するコマンド

head

指定したバイト数のデータを読むために利用する。 たとえば320バイトのデータを標準入力から読みたい場合は

head -c320

とする。

指定バイト数の読み込みであれば dd コマンドも適任であり、 Nバイトを読みたいなら

dd bs=1 count=N 2> /dev/null

によって取得できるが、N が大きい場合に遅すぎて使えない。 bs=N count=1 とすると高速だが、N が巨大すぎると受けつけてもらえない。 後続する章で画像などの大きなデータを読み取る処理でも用いることを考え 本稿では head -c を利用する。もっとも、read システムコールを指定バイト数だけ行なえばよいので、 自作のプログラムでも十分作成可能だといえる。

sed

Stream EDitor の略で、文字列の置換、選択出力などが行なえるフィルタ。 本稿では主に文字列置換と指定した行数の出力のために利用する。 文字列置換は

sed -e 's/置換前パターン/置換後文字列/'
sed -e 's/置換前パターン/置換後文字列/g'

の書式で利用する。パターンは正規表現で指定する。 1つ目は読み込んだ1行のうちパターンにマッチした部分を1個だけ置換する。 2つめ、g フラグを末尾に付したものはマッチした部分が複数あればすべて置換する。 行数指定の出力は以下のような利用を想定する。

sed 10q			# 先頭10行のみ出力 (10行目でquitの意)
sed -n 2,4p		# 2行目から4行目のみ出力 (print)
sed 1,5d		# 先頭5行のみ削除 (delete)

sed はデフォルトでバッファにある行を出力するが、-n オプションはデフォルトでの出力を抑止する。このため p コマンド(print)と組み合わせて、特定の行のみ出力できる。

tr

文字単位の1対1置換を行なう。

tr '%+' '= '		# %を=に、+をスペースに置換する
tr a-z A-z		# (文字クラス指定)小文字を大文字に置換する
tr -d '\n'		# 改行文字を削除する(-d オプションが該当文字削除)

なお、SystemVのtrで文字クラス指定するときは、 大括弧で括る必要がある(tr '[a-z]' '[A-Z]' とする)。

nkf

Network Kanji Filter の略で元は漢字コードを変換するための フィルタだが、現在ではMIMEエンコードなどの処理ができるよう拡張されている。 CGI経由で送られて来る値のうち非ASCII文字を含むものは パーセントエンコード(URL エンコード)される。これを元のバイト列に 復元するときに sed と nkf を組み合わせて以下のように行なう(UTF-8 の場合)。

tr '%+' '= '|nkf -Ww -mQ

パーセントエンコードは、%16進2桁 で符号化するが、% 文字を = に変えると quoted-printable となり、 nkf -mQ で復号化できるようになる。また、パーセントエンコードでは スペース(" ")を + 文字に変えるため、戻す場合は + をスペースに置換する。

シェルスクリプトによる簡単なCGI

CGIについて

CGIそのものの解説は多くの文献があるので詳細はそちらに譲り、 ここでは最も単純なCGIを例示するに留める。

CGIプログラムは、通常 form 文を持つHTMLページから起動される。 以下のようなHTML文書があったとする。

cgi0.html

<!DOCTYPE html>
<html lang="ja">
<head><title>CGI Example</title>
</head>

<body>
<h1>最も単純なCGI</h1>
<form action="./cgi0.cgi" method="POST">
 <label>お名前: <input name="onamae"></label><br>
 <label>ひとこと: <input name="hitokoto"></label><br>
 <input type="submit" value="OK">
 <input type="reset" value="Reset">
</form>
</body>
</html>

このHTML文書をブラウザで開くと以下のような入力画面が現れる。

最も単純なCGI



2つの入力窓と2つのボタンがある。これらをコントロール といい、CGIプログラムになんらかの値を渡すための機構になっている。 実際に利用者が入力した値を保持するのは2つの input 要素で、それぞれ name 属性によって名前が付いている。起動されたCGIプログラムは このname属性名を用いて値を取得できる。入力のための input 要素に付けられたname属性値を、以後「入力名」と表すことにする。

CGIフォームの内訳

翻ってCGIスクリプト起動のために重要な行について説明する。

<form action="./cgi0.cgi" method="POST">

HTMLのform要素にてフォームの枠組みを指定する。 action 属性に起動したいプログラムを method 属性 にデータの送信方法を指定する。method 属性について、 本稿で利用するのは以下の2つである。

GET

GET(formのmethod) CGIの場所を示すURLに受け渡すデータも埋め込む。 ブラウザによって送れるパラメータ長に限りがある。 また、URLに渡す値が見えるため、保護したい情報を送る場合には適さない。 GETで渡されたパラメータをシェルスクリプトから受け取るには環境変数 QUERY_STRING の値を読み取ればよい。

POST

POST(formのmethod) CGIプログラムの標準入力にデータを送り込む。 POSTで渡された値をシェルスクリプトから受け取るには 標準入力を読めばよい。ただし読み取るバイト数は決まっていて、それは 環境変数 CONTENT_LENGTH の値で取得する。

続いてinput要素の例を示す。

 <label>お名前: <input name="onamae"></label><br>
 <label>ひとこと: <input name="hitokoto"></label><br>

この2行では、2つの入力フォームを出している。 CGIスクリプトには onamae と hitokoto に入れられた値が渡される。

 <input type="submit" value="OK">
 <input type="reset" value="Reset">

2つある input 要素の type=submit は送信ボタン、type=reset は入力値のリセットボタンである。いずれも1つの form 要素内に複数設置でき、どれを押しても送信・リセットの働きをする。

シェルスクリプトによるフォーム受信

このような入力フォームからの送信を受け取るシェルスクリプトの例を示す。

cgi0.cgi

#!/bin/sh

parsepars() {
  oifs="$IFS"			# シェルの単語区切り文字を保存
  IFS='&'
  for unit in $1; do		# & で文字列を分割して unit に代入して繰り返す
    n=${unit%%=*}		# 入力名=値 の「入力名」を取り出す
    v=${unit#*=}		# 入力名=値 の「値」を取り出す
    echo "$n = $v"		# 「入力名 = 値」を出力するだけのプログラム
  done
  IFS="$oifs"			# IFS変数を元に戻す
}

case "$REQUEST_METHOD" in
  get|GET)			# GETの場合は環境変数から取得
    par="$QUERY_STRING" ;;
  post|POST)			# POSTなら $CONTENT_LENGTH だけ標準入力を読む
    par=`head -c $CONTENT_LENGTH` ;;
esac

cat<<EOF			# Content-type 行は必須。空行で終わる。
Content-type: text/plain; charset=utf-8

EOF
echo "実際のパラメータ文字列:"
echo "$par"
echo "分解結果:"
parsepars "$par"

注意すべきはパラメータを含む文字列の取得とその分解方法である。

case "$REQUEST_METHOD" in

REQUEST_METHOD(環境変数) 環境変数 REQUEST_METHOD にはHTML文書のform要素で指定した method が代入されているのでそれを基に処理を切り替える。POST の場合は標準入力を $CONTENT_LENGTH バイト読めばよい。 CONTENT_LENGTH(環境変数) 指定バイト数だけ読むために head コマンドの -c オプション(バイト数指定)を利用している。

IFS='&'
for unit in $1; do

シェルの単語区切りの挙動を決定するシェル変数 IFS に & 文字のみを指定してから for 文による単語分割を利用している。

実際に入力フォームに文字列を以下のように入れた場合の結果を示す。

入力場所入力名(name属性値)
お名前onamaehanako
ひとことhitokotoあい うえお

CGIスクリプトからの出力は以下のようになる。

実際のパラメータ文字列:
onamae=hanako&hitokoto=%E3%81%82%E3%81%84+%E3%81%86%E3%81%88%E3%81%8A
分解結果:
onamae = hanako
hitokoto = %E3%81%82%E3%81%84+%E3%81%86%E3%81%88%E3%81%8A

入力文字列に含まれる空白文字(0x20)は + に、非ASCII文字は パーセントエンコードされているのが分かる。また、 フォーム内での入力名すべてに対して

入力名1=値1&入力名2=値2...

のように、区切り文字 & で代入形式を列挙したものが渡される。 なお、フォームでは同じ入力名を複数付けてもよく、その場合は 同じ入力名に対する「入力名=値」の並びが複数現れることになる。 たとえばHTMLフォームで、

<input name="foo">
<input name="bar">
<input name="foo">

のような3つの入力窓に対してそれぞれ a, b, c を入力した場合、 cgi0.cgi スクリプトで得られたものを出力すると以下のようになる。

foo=a
bar=b
foo=c

必ずしも1つではない。

非ASCII文字のデコード

パーセントエンコードされたものを戻す方法の一つとして trとnkfを利用する方法を示した。これを先述の cgi0.cgi に組み込むには、以下の修正をすればよい。

    v=${unit#*=}		# 入力名=値 の「値」を取り出す

この行の部分を以下のように変える(下に1行追加)。

    v=${unit#*=}		# 入力名=値 の「値」を取り出す
    v=`echo "$v" | tr '%+' '= ' | nkf -Ww -mQ`

これに対して前回同様以下のような入力値を与える。

最も単純なCGI



送信ボタンを押すと以下の結果が得られる。

実際のパラメータ文字列:
onamae=hanako&hitokoto=%E3%81%82%E3%81%84+%E3%81%86%E3%81%88%E3%81%8A
分解結果:
onamae = hanako
hitokoto = あい うえお

今度は日本語文字列を取り出せた。なお、input要素などの name 属性に日本語を与えることもできるが、その場合は CGI スクリプトの方で入力名の方も値同様デコードすればよいだけである。

受信データの値の保存

cgi0.cgi スクリプトでは、送信されたデータから得た値をデコードしてすぐ echo で出力して終了している。実際には、渡されたデータから得た 「入力名」と「値」を以後の処理で使うために継続的に保持していなければならない。 シェルスクリプトで値を保持するにはシェル変数にスカラ値を代入するしかない。 zshやbashにはハッシュ(連想配列)があり、CGIからの値の保持に適しているが、 shではハッシュは使えない。shではスカラ変数しか使えないので、 配列やハッシュをエミュレートするために変数名を工夫して eval を用いて代入操作をすることもあるが、eval の不用意な利用は潜在的なセキュリティリスクを含むため極力避けたい。

本稿の目的の一つは RDB の操作に慣れることでもあるので、CGI から得た値を RDB に入れて保持する方法を採ることにし、その方法を述べる。 変数代わりに RDB を利用するのは速度的に有利ではないが、 シェルスクリプトで対処したい程度のデータ量とアクセス頻度であれば さほど致命的ではない。

フォームから得た値を保存するスキーマ

CGIスクリプトではHTMLフォームから得た値は「入力名=値」の集合として 取得できる。ただし、「入力名」はHTMLフォーム内で重複することがありえるため 設計するテーブルの該当カラムにはUNIQUE制約を付けられない。 これを考慮して、以下のようなテーブル設計が考えられる。

CREATE TABLE cgiparam(
  name	text,
  value	text,
  UNIQUE(name, value)
);

(入力値, 値) の全く同じ組み合わせはあってもしかたないので UNIQUE(name, value) とした。さらにもう一点、CGI スクリプトは複数のクライアントによってほぼ同時に起動されることもある。 それゆえ、別プロセスで起動したCGIスクリプト間で RDB に格納した値の取り合いをしないよう工夫する必要がある。

プロセスごとのフォームデータ分離

CGIスクリプトは複数同時起動することがあるが、それぞれ異なる PID で動く。この性質を利用して、PIDを用いて生成した文字列をタグとして フォームデータを分離する識別子とする。つまり、(入力名, 値) だけの組ではなく、 (タグ, 入力名, 値) の3つ組で管理する。 タグ情報を含んだテーブル設計は以下のようになる。

タグ情報格納テーブル tags
カラム用途
idTEXTプロセス固有のタグ
expireTEXTタグの有効期限
フォーム値格納テーブル cgipars
カラム用途
tagTEXTプロセス固有のタグ(tags.idのもの)
nameTEXT入力の名前
valTEXTnameに対応する値

これをSQL文で表したものを以下に示す。

CREATE TABLE tags(id text PRIMARY KEY, expire TEXT);
CREATE TABLE cgipars(
  tag text, name text, val text,
  FOREIGN KEY(tag) REFERENCES tags(id) ON DELETE CASCADE
);

外部キー制約として「cgipars.tag は tags.id に含まれなければならない」という条件を指定した。 また、「ON DELETE CASCADE」指定は、制約の親となる tags.id の値が DELETE された場合に、その値を含む cgipars テーブル内の行も残らず自動削除することを意味する。SQLite3 では、制約に関係する親側テーブルの値に変更が起きたときに自動的にアクションを 起こす設定ができる。

ON DELETE アクション

制約となっているキーの親側の値が削除されたときのアクションを設定する。

ON UPDATE アクション

制約となっているキーの親側の値が変更されたときのアクションを設定する。

「アクション」として指定できるのは以下のいずれかである。

SQLite3 の外部キー制約についての詳細は https://www.sqlite.org/foreignkeys.html 参照。

さらに、以下の点を考慮に加える。

フォーム値をDBに保存するスクリプト

このような指針で作成したシェルスクリプト cgi2.cgi を示す。

cgi2.cgi

#!/bin/sh
PATH=/usr/local/sqlite3/bin:$PATH
db=db/cgi.sq3 # 【1】
query() { sqlite3 -cmd '.timeout 3000' -cmd 'PRAGMA foreign_keys=on' $db "$@" }
# 【2】 tag=`date +%s`.$$ # EPOCH秒とPID値の合成 exp='+1 hour' # 1時間後
storeparam() { oifs="$IFS" # シェルの単語区切り文字を保存 IFS='&' { cat<<EOF
BEGIN; -- 【3】 CREATE TABLE IF NOT EXISTS tags(id text PRIMARY KEY, expire TEXT); CREATE TABLE IF NOT EXISTS cgipars(
tag, name text, val text, FOREIGN KEY(tag) REFERENCES tags(id) ON DELETE CASCADE );
-- 【2】 INSERT INTO tags VALUES('$tag', datetime('now', 'localtime', '$exp')); DELETE FROM tags WHERE expire < datetime('now', 'localtime');
EOF for unit in $1; do # & で文字列を分割して unit に代入して繰り返す n=${unit%%=*} # 入力名=値 の「入力名」を取り出す v=${unit#*=} # 入力名=値 の「値」を取り出す # SQLでは文字列中のシングルクォート(')は2つ重ねてエスケープ(sed部分)
# 【4】 n=`echo "$n" | tr '%+' '= ' | nkf -Ww -mQ | sed -e "s/'/''/g"` v=`echo "$v" | tr '%+' '= ' | nkf -Ww -mQ | sed -e "s/'/''/g"`
cat<<EOF REPLACE INTO cgipars VALUES('$tag', '$n', '$v'); EOF done echo "COMMIT;" } | query IFS="$oifs" # IFS変数を元に戻す } case "$REQUEST_METHOD" in get|GET) # GETの場合は環境変数から取得 par="$QUERY_STRING" ;; post|POST) # POSTなら $CONTENT_LENGTH だけ標準入力を読む par=`head -c $CONTENT_LENGTH` ;; esac
cat<<EOF # 【5】今回は text/html Content-type: text/html; charset=utf-8 <!DOCTYPE html> <html lang="ja"> <!-- table開始行まで出力しておく --> <head><title>CGI test</title></head> <body> <h1>CGI test</h1> <table border="1"> EOF
storeparam "$par" # 受け取ったパラメータをDBに格納 query<<EOF # DBに入れたあとHTMLのtable形式で取り出す
-- 【6】出力モードをHTML、ヘッダ出力をONに .mode html .head on SELECT name,val FROM cgipars WHERE tag='$tag';
EOF echo '</table></body></html>'

このスクリプトの要点について以下で説明を加える。

有効期限つきタグ

【1】の部分:

db=db/cgi.sq3

データベースファイルを別ディレクトリである db/ に置いている。SQLite では、 データベースファイルに書き込みたい場合はファイルそのものだけでなく、 ファイルを置くディレクトリも(httpd プロセスから)書き込み可能にしなければならない。 かといってCGIスクリプトの存在するディレクトリを書き込み可能にしては スクリプト本体も壊されかねないので、 書き込み可能とするディレクトリは別に作成する。CGI 起動実験を行なう前に以下の操作をしておく。

mkdir -m 1777 db
ls -lFd db
drwxrwxrwt  2 hanako  users  3 Sep 26 09:54 db/

モード指定 1777 の最上位ビットの1はStickyビットで、 このディレクトリに対してはモード777(rwxrwxrwx)であるゆえ 誰でもファイルの追加はできるが、ファイルの所有者以外は 削除できないようにする働きを持つ。

また、このディレクトリのファイルをHTTPで取得されないようにする。 Apache であれば db/.htaccess を作成し、以下のように書き込む。

<Files *>
  deny from All
</Files>

Apche 以外の場合、もしくは Apache 利用の場合でも CGI 公開ディレクトリ配下にデータベースファイルを置かないようにすれば、 不慮のデータベースファイル漏洩は防げる。本稿では手近に実験する利便で db/ ディレクトリに格納する例を示すが、 全く別のディレクトリを絶対パスで指定してもよい。たとえば

db=/some/other/path/where/httpd/cannot/access/to/cgi.sq3

のように Web 公開ファイルを置くツリーと違う場所にファイルを置くのもよい方法である。

【2】(二箇所):

tag=`date +%s`.$$		# EPOCH秒とPID値の合成
exp='+1 hour'			# 1時間後

date +%s はUNIX時間 の秒数での出力である。タグの値はUNIX時間とプロセスIDの結合値とし、同時に動いている他のプロセスの同じスクリプトと競合しないようにしている。 このあと読み取るフォームからの値をデータベース格納するときにタグと入力名、 値を組にしてINSERTする。

1970年1月1日0時0分(UTC)を起点とする経過時間で、 コンピュータシステムでは内部でこの時間を保持している。 エポック(Epoch)ともいう。

有効期限の1時間後の時刻を求めるときに、SQLite3 の datetime 関数を利用する。ここでは、

datetime('now', 'localtime', '+1 hour')

という関数呼び出しで、1時間後の時刻を表す文字列を得ている。 これを挿入しているのが【2】の印のある下の方の部分である。

INSERT INTO tags VALUES('$tag', datetime('now', 'localtime', '$exp'));
DELETE FROM tags WHERE expire < datetime('now', 'localtime');

現行プロセス固有のタグを有効期限の時刻文字列と合わせてINSERTしている。 同時に、過去に起動されたプロセスにより挿入されたタグについて、 そのときに設定した有効期限を過ぎていたらテーブルから削除するようにしている。

なお、datetime() の挙動を見るにはsqlite3コマンドでいくつか試すとよい。

sqlite3
SELECT datetime('now');
2015-10-15 13:29:19
SELECT datetime('now', 'localtime');
2015-10-15 22:29:04
SELECT datetime('now', 'localtime', '+8 hours');
2015-10-16 06:29:44

【3】の部分では、「テーブルが存在しなければCREATE TABLE」を IF NOT EXISTS 句を用いて発行している。このため、 事前のテーブルの初期化はしなくても済むようにしている。

データベースへの値の文字列挿入

【4】の部分:

      n=`echo "$n" | tr '%+' '= ' | nkf -Ww -mQ | sed -e "s/'/''/g"`
      v=`echo "$v" | tr '%+' '= ' | nkf -Ww -mQ | sed -e "s/'/''/g"`

nkf -Ww -mQ までのフィルタ処理で非ASCII文字も元の文字列に戻る。 もしその文字列にシングルクォートが含まれていた場合は、続く REPLACE 文でエラーを引き起こすことになる。

REPLACE INTO cgipars VALUES('$tag', '$n', '$v');

たとえば v="Let's Go" だとすると、 上記の REPLACE 文は以下のようになる。

REPLACE INTO cgipars VALUES('$tag', '$n', 'Let's Go');

$v 内のシングルクォートにより、文字列を括るクォートが終わるため SQL 構文エラーとなる。スクリプト中のコメントにもあるように、 文字列中にクォートを入れたい場合は1つのクォートを2つ連ねればよい。 この変換を行なっているのが nkf 起動の後ろの

sed -e "s/'/''/g"

である。

HTMLの出力

【5】の部分:

cat<<EOF
Content-type: text/html; charset=utf-8

<!DOCTYPE html>
<html lang="ja">
  :
  :
EOF

今回はHTML形式で出力するので、Content-type ヘッダをHTML用にしている。 sqlite3 でのHTML形式での出力は、tr要素の内部のみであるからそれを包含する table要素はシェルスクリプトで用意する。

<!DOCTYPE html> <html lang="ja"gt; <head><title>●×▲</title></head> <body> 《スクリプトが出力する部分》 ※◆○…… <table border="1">
<tr>...</tr> 《sqlite3が出力する部分》 <tr>...</tr>
</table> </body> 《スクリプトが出力する部分》 </html>

【6】の部分:

.mode html
.head on
SELECT name,val FROM cgipars WHERE tag='$tag';

sqlite3 への入力はこの3行で、最初の2行で出力形式を HTML にして ヘッダ出力をONにしている。ヘッダ出力はカラム名をth要素で書き出すようになる。

実行結果

以下のように onamae に対して「hanako」を、 hitokoto に対して「<Let's えんじょい>」の入力値を与えて cgi2.cgi を呼び出してみる。

受信値をDBに保存するCGI



結果は以下のようになる(table部分のみ示す)。

name val
onamae hanako
hitokoto <Let's えんじょい>

この出力が得られた状態で、データベースファイルに直接アクセスして 格納されている値を見ると以下のようになっている。

sqlite3 db/cgi.sq3
SELECT name,val FROM cgipars;
onamae|hanako
hitokoto|<Let's えんじょい>
.mode html
SELECT name,val FROM cgipars;
<TR><TD>onamae</TD>
<TD>hanako</TD>
</TR>
<TR><TD>hitokoto</TD>
<TD>&lt;Let&#39;s えんじょい&gt;</TD>
</TR>

注目すべきは、hitokoto への入力に与えたクォートと不等号である。 クォートは SQL の文字列括りであるが、sed による2連並びへの置換で正常に INSERT 処理できたことが分かる。また、不等号は HTML としてそのまま出力するとタグ開始と判定されて消えてしまうのだが、sqlite3 は HTML 出力モードに変えると、不等号記号等を HTML の実体参照に置き換える。 これにより、シェルスクリプトで CGI プログラムを作る場合に記号類の実体参照化を sqlite3 にすべて任せることができる。

HTMLで特別な意味を持つ記号など、 その文字自体を出力するための参照方法で、たとえば < は &lt; で表記する。

セキュリティに対する考察

コマンドラインで起動すること前提のスクリプトでは、 引数に与えられた値をそのまま INSERT 文に渡していた。 これは利用者がデータベースを破壊する意志がないことを前提としたからで、 Web アプリケーションではその前提は置けない。cgi2.cgi で入力値を

v=`echo "$v" | tr '%+' '= ' | nkf -Ww -mQ | sed -e "s/'/''/g"`

と、最後の sed 置換でクォート変換したのはその対策の意味もある。 データベースに対するありがちな攻撃として次のようなものがある。たとえば

INSERT INTO tbl VALUES('$k', '$v');

というSQL文に対して、k と v の値に次のようなものを入れてみる。

k=foo
v="Akkanbeee!'); DROP TABLE tbl; SELECT ('Bomb!"

するとSQL文は次のように展開される。

INSERT INTO tbl VALUES('foo', 'Akkanbeee!'); DROP TABLE tbl; SELECT ('Bomb!');

このように、入力値に副作用をもたらすSQL文を注入することを SQLインジェクション といい、 入力値をデータベースに挿入する際には必ずそれが起きないよう注意する必要がある。

SQLiteを含めたRDBMSの各種プログラミング言語のライブラリ(言語バインディング) では、INSERT 文とそれに与える値を分離して処理(プレースホルダ利用) することで予期せぬ SQL 文実行を防げるが、シェルスクリプトではできない。 このために次のような前処理を欠かさず行なう必要がある。

文字列の場合

数値の場合

数値として読み込んでいる値であれば、数値として登場しえない文字を削る。 たとえばシェル変数に入っている値が数値であるという仮定ならば、

v=${v%%[!-0-9Ee.]*}

とすると、数値以外の文字が1つでもあればそれ以降の文字がすべて削除される。 自然数に限定できるのなら、

v=${v%%[!0-9]*}

とさらに厳しくでき、これらに続けて「$v が空文字列でなければ」(test -n "$v") の条件分岐を足すことで不正SQLは防げる。

ライブラリ化

HTML文書からのフォームデータを受け取り、 データベースに格納する処理はすべてのCGIスクリプトで必要になるので ライブラリ化しておくと以後作成するスクリプトで役に立つ。

ここで定義している getpar 関数は

x=`getpar param`

として、paramという name 属性の付いた値を取得する。 また、フォーム送信時にパーセントエンコードされた文字列を元に戻す処理も 頻繁に使うため関数化しておく。 その他、有用な関数をまとめてファイル化しておく。

cgilib-sh

#!/usr/bin/head -5
# -*- mode: shell-script -*-
# CGI Library for Shell Script
# Use this by source'ing.
# . ./cgilib-sh

PATH=/usr/local/sqlite3/bin:$PATH
_db=db/cgi.sq3
query() {
  sqlite3 -cmd '.timeout 3000' -cmd 'PRAGMA foreign_keys=on' $_db "$@"
}
_tag=`date +%s`.$$		# CGIパラメータのタグをEPOCH秒とPID値から生成
_exp='+1 hours'			# 1時間後
pdecode() {
  tr '%+' '= '| nkf -Ww -mQ
}
storeparam() {
  oifs="$IFS"			# シェルの単語区切り文字を保存
  IFS='&'
  { cat<<-EOF
	BEGIN;
	CREATE TABLE IF NOT EXISTS tags(id text PRIMARY KEY, expire TEXT);
	CREATE TABLE IF NOT EXISTS cgipars(
	  tag, name text, val text,
	  FOREIGN KEY(tag) REFERENCES tags(id) ON DELETE CASCADE
	);
	INSERT INTO tags
	  VALUES('$_tag', datetime('now', '$_exp', 'localtime'));
	DELETE FROM tags WHERE expire < datetime('now', 'localtime');
	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"`
      cat<<-EOF
	REPLACE INTO cgipars VALUES('$_tag', '$n', '$v');
	EOF
    done
    echo "COMMIT;"
  } | query
  IFS="$oifs"			# IFS変数を元に戻す
}
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></head>
	<body>
	<h1>$1</h1>
	EOF
}

case "$REQUEST_METHOD" in
  get|GET)			# GETの場合は環境変数から取得
    par="$QUERY_STRING" ;;
  post|POST)			# POSTなら $CONTENT_LENGTH だけ標準入力を読む
    par=`head -c $CONTENT_LENGTH` ;;
esac

storeparam "$par"		# 受け取ったパラメータをDBに格納

日程調整システムへの適用

ここまで説明したCGI処理を応用して日程調整システムの Web インタフェースを作成しよう。 この段階では単純化してユーザ認証機能はないものとし、 互いに信頼できる者どうしが本人を偽ることなく情報入力する と仮定が置けるものとする。

処理スクリプトは以下の3つの機能を持つように設計する。

  1. 入力フォームの出力
  2. 入力値の登録処理
  3. 登録状態の表形式出力
★★図にする図にする図にする★★

    (1回目)                                              (2回目)
 [スクリプト起動]  →  [入力Webページ]     →       [スクリプト起動]
                         利用者が入力   送信ボタン    入力値の登録処理

入力フォームの生成

日程調整スクリプトのコマンドラインでのデータ入力を振り返ってみる。

./imoni.sh -a 飯森花子
10/3=yes
10/4=yes
10/10=no
10/11=no
10/17=no
10/18=no

ここで imoni.sh に与えているのは「氏名」と「日付ごとのyes/no」である。 これらを読み取るためのWebインタフェースを作ればよい。そのための 入力フォームを以下のように設計してみる。

お名前:

登録自体の削除

どのようなHTML文を生成すればよいか順に見ていく。

「お名前」の次の入力窓は type="text" の input 要素である。 日付ごとのチェックボックスは type="checkbox" の input 要素である。 この日付は一覧を納めたファイルが datelist.csv にあるのでそこから取得しつつ対応するチェックボックスを生成する。 「登録自体の削除」もチェックボックスで生成する。

日付ごとのチェックボックス生成は、datelist.csv を読みながら以下のように行なう。

for d in `cat datelist.csv`; do
  cat<<EOF
<li><label><input type="checkbox" name="$d" value="yes">
	$d</label></li>
EOF
done

値の受け取りとデータベースへの登録

フォームに値が入力されて起動されたCGIスクリプトからは、 入力値は cgilib-sh 内の getpar 関数を呼ぶことで得られる。たとえば 氏名の入力されている onamae 欄の値は x=`getpar onamae` などとすることで得られる。また、日付ごとの参加 yes/no の回答は getpar 日付 で得られる。日付一覧を datelist.csv から取得するとすれば、次のようにするとすべての参加可否が得られる。

for d in `cat datelist.csv`; do
  case `getpar $d` in
    yes)	ans="yes" ;;
    *)		ans="no" ;;
  esac
  echo $d=$ans
done

これによって、「10/3=yes」、「10/4=no」... などの文字列が生成される(のちに練習問題で改良)。

以上の設計を反映させた日程調整Webインタフェース提供スクリプトの例 imoniweb0.cgi を以下に示す。

imoniweb0.cgi

#!/bin/sh
cd `dirname $0`				# カレントディレクトリを合わせる
. ./cgilib-sh
DB=db/cgi.sq3; export DB		# データベースファイルを cgi.sq3 に統合
./imoni2.sh -i				# 関連テーブル初期化関数を呼んでおく

htmlhead "芋煮予定調整表"		# ヘッダの出力(cgilib内の関数)

###
## 入力フォームの生成
###
cat<<EOF
<p>参加できる日程をチェックして[送信]ボタンを押してください。</p>
<form action="./imoniweb0.cgi" method="POST">
 お名前: <input name="onamae">
 <ul>
EOF
for d in `cat datelist.csv`; do
  cat<<EOF
 <li><label><input type="checkbox" name="$d" value="yes">
	$d</label></li>
EOF
done
cat<<EOF
 </ul>
 <input type="checkbox" name="delete" value="yes">登録自体の削除<br>
 <input type="submit" value="送信">
 <input type="reset" value="リセット">
</form>
EOF

###
## 入力された値がもしあればデータベースに登録
###
name=`getpar onamae`
if [ -n "$name" ]; then			# 氏名が記入されていたら登録
  delete=`getpar delete`
  if [ x"$delete" = x"yes" ]; then
    ./imoni2.sh -d "$name"		# 削除
  else
    for d in `cat datelist.csv`; do	# これを imoni2.sh -a のstdinに渡す
      case `getpar $d` in
	yes)	ans="yes" ;;
	*)	ans="no" ;;
      esac
      echo $d=$ans			# 日付=yes/noの出力
    done | ./imoni2.sh -a "$name"
  fi
fi

###
## 登録状況を表形式で出力
###
./imoni2.sh -l				# 登録後の○×数え上げ表を出力

echo "</body></html>"

このCGIスクリプトを配置し、Web ブラウザから開いた画面は以下のようになる(入力中の様子を示す)。

芋煮予定調整表

お名前:

参加できる日程をチェックして[送信]ボタンを押してください。

登録自体の削除

この状態で[送信」ボタンを押すと「10/3」と「10/4」に○が付くよう データベースが更新される。

芋煮予定調整表

お名前:

参加できる日程をチェックして[送信]ボタンを押してください。

登録自体の削除

name 10/310/410/1010/1110/17 10/18
飯森花子 ×××
○の合計110001

わずか50行足らずのスクリプトだが、SQLite の力を借りているため データの頻繁な更新に耐えうる堅牢なWebアプリケーションとして振る舞える。

練習問題: シェルでのループの回避

imoniweb0.cgi では、HTMLフォームから日付ごとにチェックされた値を imoni.sh -a に送り込む部分を for 文で行なっている。

    for d in `cat datelist.csv`; do	# これを imoni2.sh -a のstdinに渡す
      case `getpar $d` in
        ...
    done

ここでは、日付文字列1つ1つに対し、for 文によるループで毎回 getpar を呼んでいる。getpar は内部で sqlite3 コマンドをその都度呼ぶものであるから、 入力変数の個数増加に比例して呼び出し回数が増える。 この程度の規模のデータ入力操作では問題にならないが、 シェルスクリプトのループそのものをなくしたり、sqlite3 コマンドの呼び出しを極力減らすことで効率化をはかれる。

予定調整の imoniweb0.cgi では、imoni2.sh -a に渡す文字列の生成を for ループを用いていた。これを改良し、SQL 文1つで文字列生成を行なえるようにせよ。

練習問題: 解答例

datelist テーブルと sched テーブルで LEFT JOIN をした結果を 「日付」=「yesまたはno」の書式で出力すればよい。取り出したいカラムは datelist.date と cgipars.val であるから、SQL 文を次のように組み立てる($_tag の部分はその CGI セッションタグに置き換えられるものとする)。

SELECT d.date, c.val
FROM datelist d LEFT JOIN cgipars c
     ON d.date=c.name AND c.tag='$_tag';

このままだと NULL が空欄になるため、coalesce で 'no' に置き換える。

SELECT d.date, coalesce(c.val, 'no')
FROM datelist d LEFT JOIN cgipars c
     ON d.date=c.name AND c.tag='$_tag';

最後に「日付」=「yesまたはno」にするために、SQLの文字列結合 || で出力を整形する。

SELECT d.date || '=' || coalesce(c.val, 'no')
FROM datelist d LEFT JOIN cgipars c
     ON d.date=c.name AND c.tag='$_tag';

以上により出力は次のようになる。

10/3=yes
10/4=yes
10/10=no
10/11=no
10/17=no
10/18=yes

printf 愛好家であれば、SELECT の直後を以下のように書き変えてもよい。

printf('%s=%s', d.date, coalesce(c.val, 'no'))

以上をまとめると、imoniweb0.cgi の最後の for を次のように修正すればよいことになる。

for d in `cat datelist.csv`; do
  case `getpar $d` in
	yes)	ans="yes" ;;
	*)	ans="no" ;;
  esac
  echo $d=$ans
done | ./imoni2.sh -a "$name"
query<<EOF | ./imoni2.sh -a "$name"
SELECT d.date || '=' || coalesce(c.val, 'no')
FROM datelist d LEFT JOIN cgipars c
     ON d.date=c.name AND c.tag='$_tag';
EOF

なお、この修正を終えてもまだ改良すべき点は残る。 データベース内で解決できるデータのやりとりなのに imoni2.sh を介している点である。SELECT 文の組み立てをもう一工夫して、 sched テーブルにそのまま入力可能な表形式とすることで、 シェルスクリプトを介することなくすべての更新処理が可能となる。

以下に、cgipars から直接 sched に反映させる SQL 文の例を示しておくので、これをシェルスクリプトにうまく組み込んでみてほしい。

REPLACE INTO sched
  SELECT (SELECT val FROM cgipars
          WHERE tag='$_tag' AND name='onamae') name,
       d.date,
       CASE c.val
       WHEN 'yes' THEN '○'
       ELSE '×'
       END okng
  FROM datelist d LEFT JOIN cgipars c
       ON d.date=c.name AND c.tag='$_tag';
yuuji@koeki-u.ac.jp