PHPで作る掲示板のような追記形メモ

はじめに

入力した見出しと記事を画面に付け加えて行くPHPプログラムを作ります。

サーバーへ入力した文字列をPOSTで送信し、formで指定するページに遷移します(つまり新しいページをサーバーに要求します)。サーバーでは新しいページに書かれているphpプログラムに沿って送られてきた文字列をファイルに追加保存します。さらにプログラムに沿ってそのファイルを読み直し、ページを作成してクライアントに送ります。クライアントのブラウザはページを表示します。

今回のプログラムでは、送信するページと、遷移するページは同じファイルなので混乱しないように。

ログファイルと出力ファイルの設定

まず、エラー表示とログの設定をします。

また、エラーログと同じフォルダに、入力されたデータを保存するファイルも作ることにします。

memomemo.php
<?php
ini_set('display_errors',1); //公開する時は0にします。
$P_DOC_ROOT = dirname($_SERVER['DOCUMENT_ROOT']);
ini_set('error_log',$P_DOC_ROOT."/phpdata/errlog");
$RECD_FILE = $P_DOC_ROOT."/phpdata/memostore.txt"; //データ保存用ファイル
?>

PHPで、文字列の間の"."は文字列の連結を意味します。

head内でも構いませんが、bodyの最初の方に書いておくことにします。

入力のためのフォーム

ウェブページの中に、入力枠を作ります。formと呼ばれる昔から使われている仕組みです。

formのaction=には、たとえば"memomemo.php"などと、データを送信して呼び出すページのURLを入れるのですが、これをPHPにやらせます。自分自身のファイル名になります。このフォームを"memomemo.php"ではないファイルに書いても、追随してそのファイルの名前にしてくれるので便利です。

__FILE__はファイルのフルパスとファイル名を表す、"マジック"定数です。basename()はそれから最後のファイル名だけを取り出す関数です。

memomemo.php(入力フォーム部)
<h1>メモメモ</h1>
<h2>記事の入力</h2>
<form action="<?php echo basename(__FILE__); ?>" method="post">
  <div class="formrow">
    <label for="title1">見出</label>
    <input type="text" name="title" id="title1" placeholder="title">
  </div>
  <div class="formrow">
    <label for="message1">記事</label>
    <textarea  name="message" id="message1" placeholder="message"></textarea>
  </div>
  <div class="formrow">
    <span class="formcell"> </span>
    <!--<input type="submit" id="ft" value="送信" class="footer">-->
    <span class="formcell">
    <button type="submit" name="button" value="add" >追加</button>
    <button type="submit" name="button" value="clear">消去</button>
    </span>
  </div>
</form>

基本的には、formタグの中に、inputタグなどを置き、そこに文字列を入力。submitのボタンを押すと送信されるという仕組みです。inputとtextareaが1つずつ、ボタンが2つあります。

class="formrow"としたdivタグは表示する時の行の区切りです。

1行目はlavelとinput、2行目はlabelとtextareaを並べます。

3行目はspanを2つ並べていますが、ひとつ目は空白のためのダミーです。2つめのspanにボタンが2つ並んでいます。

以前はtableに仕立てたのですが、cssでtable,table-row,table-cellの指定ができるようになったので、これを使っています。cssは次のように書いています。

memomemo.phpのcss部分
form {
  display: table;
}
div.formrow {
  display: table-row;
}
span.formcell,
label, input, textarea {
  display: table-cell;
  margin-bottom: 10px;
  padding:4px 4px;
}
textarea{
  vertical-align:middle;
}

スクリーンショットです。

このformの見え方
欄内に見える文字はplaceholder属性の値。入力されると消える

POSTで渡されるデータを確認

formタグにmethod="post"と書きましたので、POSTの形式でデータが渡されてきます。これは送信先のPHPプログラムには $_POST という定義済み変数に格納されてきます。データの形式はPHPで配列と呼ばれる形式です。

PHPの配列は値を値の名前(キー)に関連付けたマップです。$_POSTの内容を全部表示させてみると理解しやすいので、次の部分をmemomemo.phpに付け加えて確認しましょう。

memomemo.php(作成途中のテスト)
<h2>データの確認</h2>
<pre>
<?php
$tmp = var_export($_POST,true);
$tmp = htmlspecialchars($tmp,ENT_QUOTES);
echo $tmp;
?>
</pre>

簡単には、var_export($_POST);だけでもいいのですが、入力された値をそのままページに表示すると、スクリプトなどを書き込まれた時に危険なのでhtmlspecialchars()関数を使って用心をしています。具体的には<などの5つのhtml用特殊記号を&lt;などのhtmlエンティティという文字列に変えて特殊機能としての機能を無効化しています。5つの記号とは、「< > " ' &」 です。

このページは未完成ですが、ここまででも、十分動きます。入力した値と$_POSTに返される値を見比べるとformの働きが理解できます。

(1)最初の表示

最初にこのページを表示した時は、

array (
)

となります。

(2)入力後のボタンで遷移した後

見出しに、test。記事に hello PHP と入力して[追加]ボタンを押すと、

array (
  'title' => 'test',
  'message' => 'hello PHP',
  'button' => 'add',
)

となります。

=>の左側がキーで、input,textareaではname=で与えられた文字列です。

右側は値で入力された文字列が入ります。

buttonのところは、入力する場所がなく、value=で与えられた文字列が値になります。どのボタンが押されたかが、"add"と"clear"で区別できます。

(3)入力が空だったとき

何も入力しないで[追加]ボタンを押すと、

array (
  'title' => '',
  'message' => '',
  'button' => 'add',
)

です。''は空の文字列を意味します。

値が定義されていない(1)と、値が''である(3)は異なる状態です。

$_POSTから個別にデータを取り出す

$_POSTは配列なので、値を個別にとるには[]内にキーを書きます。

$title = $_POST['title'];

しかし、これだと上の(1)の様にtitleというキーがなかった時にNoticeがでます。

その対策としては、isset()関数を使います。if文でもできますが、三項演算子を使うと便利です。isset($_POST['title']) がtrueの時 $_POST['title'] 、falseのときは''というのを一度に書く方法です。

$title = isset($_POST['title']) ? $_POST['title'] : '';

これで、title, message, buttonの値を取り出してページにh3要素、p要素として書き出します。

memomemo.php(値の取り出し部最終ひとつ前)
<h2>記事</h2>
<?php
$title = isset($_POST['title']) ?$_POST['title'] :'';
$message = isset($_POST['message']) ?$_POST['message'] :'';
$button = isset($_POST['button']) ?$_POST['button'] :'';
$sender  = $_SERVER['REMOTE_ADDR'] ;
//ここから後に削除
$title = htmlspecialchars($title,ENT_QUOTES);
echo "<h3>$title</h3>\n";
$message = htmlspecialchars($message,ENT_QUOTES);
echo "<p>$message</p>\n";
echo "<p>($sender)</p>\n";
//ここまで後に削除
?>

最後の$senderはページを要求してきたクライアントのIPアドレスです。$_SERVER は $_POST と同じく定義済み変数(配列)です。

ここまでを試します。画面にはこんな感じで入力して追加ボタンを押します。

送信テスト
formに入力して追加ボタンを押す

表示後のスクリーンショットです。

送信テスト
取りあえず画面に出す

ファイルに書き込みます

画面に表示できたので、これをファイルに書き込むようにします。

$titleが''の時には、書かないことにします。

memomemo.php(書き込み部最終2つ前)
//書き込み
if ( !empty($title )){
    $fp = fopen($RECD_FILE,"a");
    $date=date("Y-m-d H:i:s");
    flock($fp, LOCK_EX);
    fwrite($fp, "$title\t$message\t$date\t$sender\n");
    fflush($fp);
    flock($fp, LOCK_UN);
    fclose($fp);
}

fopenの"a"はappendで、ファイルへの追加書き込みです。

ウェブページは同時に複数のブラウザからアクセスされる可能性があります。書き込む前に他のアクセスをブロックしてから書き込んで、fflush()でバッファに溜まったものをすぐに書き込み、書き終わったらロック解除します。

dateは書き込みの日時です。

fwriteが実際に書く内容の指示です。4つの文字列をタブ区切りで行末に改行を入れて1行で書きます。

fopenしたものはfcloseします。

別解があります。

memomemo.php(書き込み部最終ひとつ前)
//書き込み(別解)
if ( !empty($title )){
    $date=date("Y-m-d H:i:s");
    $out = "$title\t$message\t$date\t$sender\n";
    file_put_contents($RECD_FILE, $out, FILE_APPEND | LOCK_EX);
}

stream_set_write_bufferの動作を確認していて、file_put_contentsを見つけました。PHP5から使えるようになっていました。fopenして読み書きし、fcloseで終了という、概念ではないやり方が流行っています。これも後で試してみます。

どちらにしても、書き出すファイルを予め作って書き込み権限を与える必要があります。エラーログの時と同じです。

adachi@adachi-CF-Y7:/var/www/html$ sudo touch ../phpdata/memostore.txt
adachi@adachi-CF-Y7:/var/www/html$ sudo chgrp www-data ../phpdata/memostore.txt
adachi@adachi-CF-Y7:/var/www/html$ sudo chmod g+w ../phpdata/memostore.txt
adachi@adachi-CF-Y7:/var/www/html$ ls -l ../phpdata/
-rw-rw-r-- 1 root www-data 503  1月 16 13:28 errlog
-rw-rw-r-- 1 root www-data   0  1月 16 19:32 memostore.txt

ファイルの読み出し

追加書き込みするファイルから読み出してページに表示する部分を加えます。

入力直後に表示する部分は削除してしまいます。

memomemo.php(読み出し部最終ひとつ前)
//読み出し
if ( file_exists($RECD_FILE) ){
  $lines = file($RECD_FILE, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES );
  $lines = array_reverse($lines);
  foreach($lines as $line){
     $line = htmlspecialchars($line,ENT_QUOTES);
     list($title,$message,$date,$sender) = explode("\t", $line, 4);
     echo "<h3>$title</h3>\n";
     echo "<p>$message</p>\n";
     echo "<p>$date ($sender)</p>\n\n";
  }
}

まずfile_exist()でファイルが無い場合にもエラーを起こさないようにしておきます。

file()関数は第一引数をファイル名として、ファイルを行ごとに読み出して文字列の配列を作ります。莫大なデータでなければ問題なくファイル全体を読み出すので便利です。これも最近のopen-closeを内部でやってしまう関数です。

さらに後ろに書いたフラグは、文字列に改行コードを残さないという指示と、空行(改行コードだけの行)を無視するという指示です。これがとても便利。

array_reverse()は配列の順序を逆転します。

foreachは配列を1要素づつ、つまりはファイルの1行づつを反復処理します。

htmlspecialchars()は、画面表示の時に必要になるものなので、忘れずに置きます。

1行をタブで4つに区切って$title,$message,$date,$senderに分けて代入します。expload()の第3引数は、代入先が4つしかないので、制限をします。省略するとタブの数+1の配列にしてしまいます。

これをh3とpのタグを付けて書き出します。

2つ追加したときのスクリーンショットです。

二項目でテスト
2つ追加しました。後のものが先になります

記事を多行化

一応完成したかに見えますが、重大な欠陥があります。記事の枠は文章を入れる想定でtextareaにしましたが、ここに2行以上入れるとNotceがでて、表示もずれたりします。

これは$messageの中に改行も文字として含まれてしまうためです。行ごとに読み出す時に$messageの途中の改行文字で途切れてしまい、タブの数が足りなくなります。次の行は$messageの続きから始まるので、ダータがずれ、やはりタブの数が足りなくなります。

もともと$messageはpタグに入れるので改行文字は半角スペースになってしまいます。pタグの中で改行するには<br>に変換する必要があります。書き込み前にこの変換をしてしまいましょう。

読み込んだ後、htmlspecialchars()関数で&lt;br&gt;になってしまいますが、これをさらに<br>に戻すことにします。

str_replace()関数は、次の$subject内の$searchを$replaceに置換します。

str_replace($search, $replace, $subject)

しかも、$search,$replace共に配列を使用すると複数の置換を順にやってくれます。

改行文字は、OSによって異なることがあるので3つ用意します。タブも入っていると項目がずれますので、スペースに変換しておきます。

$search  = array("\t","\r\n","\n"  ,"\r"  );
$replace = array(" " ,"<br>","<br>","<br>");

書き込む所

書き込みは別解を使って、

memomemo.php(書き込み部最終部品)
$search  = array("\t","\r\n","\n"  ,"\r"  );
$replace = array(" " ,"<br>","<br>","<br>");
//書き込み(別解)
if ( !empty($title )){
    $message = str_replace($search,$replace,$message);
    $date=date("Y-m-d H:i:s");
    $out = "$title\t$message\t$date\t$sender\n";
    file_put_contents($RECD_FILE, $out, FILE_APPEND | LOCK_EX);
}

//読み出す所

memomemo.php(読み出し部最終部品)
//読み出し
if ( file_exists($RECD_FILE) ){
  $lines = file($RECD_FILE, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES );
  $lines = array_reverse($lines);
  foreach($lines as $line){
     $line = htmlspecialchars($line,ENT_QUOTES);
     list($title,$message,$date,$sender) = explode("\t", $line, 4);
     $message = str_replace('&lt;br&gt;','<br>',$message);
     echo "<h3>$title</h3>\n";
     echo "<p>$message</p>\n";
     echo "<p>$date ($sender)</p>\n\n";
  }
}

クリア

書き込みの別解を使って簡単に作ります。このプログラムは実用というより習作ですから、溜まってきた試行錯誤の入力をバッサリ整理するためにあります。本来なら記事をひとつずつ書き換えたり、削除したりできるようになっているといいかもしれませんが、今回は簡単のためここまでとします。

ウェブですから複数の人がアクセスして書き込むことが可能です。でも誰でも消去できるというのも問題なので、IPアドレスで制限を設けました。これも人で認証するしくみが欲しいかもしれませんが、ここまでにしておきます。

消去したら消去したことがわかるように、読み出しの前に次のプログラムを加えます。

memomemo.php(消去部最終部品)
//消去
if ( $button == "clear" ){
  if ( $sender=="192.168.1.30" ){
    $date=date("Y-m-d H:i:s");
    $out = "新規\t開始しました\t$date\t$sender\n";
    file_put_contents($RECD_FILE, $out, LOCK_EX);
  }
}

2つあるボタンは、それぞれbuttonの値としてaddとclearを返します。このうちclearしか使っていません。clearでもtitleに値が入っていれば、データが書き込みされますが、すぐに消去されますので結果は同じです。titleが入っていない時にaddすると書き込みは行われず、消去もされないので、再表示となります。新規でこのページにきたときには、読み込みだけ行いますから、再表示と同じ動作になります。

最終プログラム

このページのプログラムを組み合わせた最終形態です。

2つ追加しています。

1. h3,pのcss 記事の見栄えのためです。

2. index.htmlに戻るボタン。

memomemo.php(全体)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>メモメモ</title>
<style type="text/css">
form {
  display: table;
}
div.formrow {
  display: table-row;
}
span.formcell,
label, input, textarea {
  display: table-cell;
  margin-bottom: 10px;
  padding:4px 4px;
}
textarea{
  vertical-align:middle;
}
h3{
  background-color:#cfc;
  margin:0.5% 0;
  padding:1px 4px;
}
p{
  margin:0.5% 3%;
}
nav{
  position: fixed;
  top: 10px; right: 10px;
}
</style>
</head>
<body>
<?php
ini_set('display_errors',0); //公開する時は0にします。
$P_DOC_ROOT = dirname($_SERVER['DOCUMENT_ROOT']);
ini_set('error_log',$P_DOC_ROOT."/phpdata/errlog");
$RECD_FILE = $P_DOC_ROOT."/phpdata/memostore.txt"; //データ保存用ファイル
?>
<nav>
<a href="index.html">目次</a>
</nav>
<h1>メモメモ</h1>
<h2>記事の入力</h2>
<form action="<?php echo basename(__FILE__); ?>" method="post">
  <div class="formrow">
    <label for="title1">見出</label>
    <input type="text" name="title" id="title1" placeholder="title">
  </div>
  <div class="formrow">
    <label for="message1">記事</label>
    <textarea  name="message" id="message1" placeholder="message"></textarea>
  </div>
  <div class="formrow">
    <span class="formcell"> </span>
    <span class="formcell">
    <button type="submit" name="button" value="add" >追加</button>
    <button type="submit" name="button" value="clear">消去</button>
    </span>
  </div>
</form>

<h2>記事</h2>
<?php
$title = isset($_POST['title']) ?$_POST['title'] :'';
$message = isset($_POST['message']) ?$_POST['message'] :'';
$button = isset($_POST['button']) ?$_POST['button'] :'';
$sender  = $_SERVER['REMOTE_ADDR'] ;

$search  = array("\t","\r\n","\n"  ,"\r"  );
$replace = array(" " ,"<br>","<br>","<br>");
//書き込み
if ( !empty($title )){
    $message = str_replace($search,$replace,$message);
    $date=date("Y-m-d H:i:s");
    $out = "$title\t$message\t$date\t$sender\n";
    file_put_contents($RECD_FILE, $out, FILE_APPEND | LOCK_EX);
}
//消去
if ( $button == "clear" ){
  if ( $sender=="192.168.1.30" ){
    $date=date("Y-m-d H:i:s");
    $out = "新規\t開始しました\t$date\t$sender\n";
    file_put_contents($RECD_FILE, $out, LOCK_EX);
  }
}
//読み出し
if ( file_exists($RECD_FILE) ){
  $lines = file($RECD_FILE, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES );
  $lines = array_reverse($lines);
  foreach($lines as $line){
    $line = htmlspecialchars($line,ENT_QUOTES);
    list($title,$message,$date,$sender) = explode("\t", $line, 4);
    $message = str_replace('&lt;br&gt;','<br>',$message);
    echo "<h3>$title</h3>\n";
    echo "<p>$message</p>\n";
    echo "<p>$date ($sender)</p>\n\n";
  }
}
?>
</body>
</html>

UTF-8正当性チェック

セキュリティ上の問題もほぼ無いと思います。ログをチェックせずに放置するならログへの記録をやめる、ini_set('log_errors','Off');がよいでしょう。正常な書き込みでもファイルは増加する一方ですから注意が必要です。スパム広告などの書き込みはこのまでは防げませんから長期運用なら認証などの対策が必要です。

最近不正な文字コードを送りつけて、負荷をかけたり誤動作をさせるという攻撃があると聞いたので、ちょっと対策を考えてみました。

フォームから入力することだけを考えるなら不要ですが、httpリクエストを直接送る分については自由に文字コードを操作できると考えられます。

memomemo.php(UTF-8正当性チェックを追加)
//書き込み
if ( !empty($title )){
  $chkt = mb_check_encoding($title,"UTF-8");
  $chkm = mb_check_encoding($message,"UTF-8");
  if($chkt and $chkm ){
    $message = str_replace($search,$replace,$message);
    ....

mb_check_encodingの導入

DebianやUbuntuでは、mb_check_encodingを使うためにパッケージが必要です。php-mbstringを入れます。debianでは、ディストリビューションの設定に基づき、PHPのバージョンにあったものが導入されます。

Start-Date: 2020-01-20  02:31:12
Commandline: /usr/sbin/synaptic
Requested-By: adachi (1000)
Install: php7.0-mbstring:amd64 (7.0.33-0+deb9u6, automatic), php-mbstring:amd64 (1:7.0+49)
End-Date: 2020-01-20  02:31:16

php-intlも導入したい

collator_asort()もついでに。日本語の辞書順を可能にしてくれます。下記は上とは違うホストのUbuntuのものです。

Start-Date: 2020-01-31  10:34:32
Commandline: /usr/sbin/synaptic
Requested-By: adachi (1000)
Install: php7.2-intl:i386 (7.2.24-0ubuntu0.18.04.2, automatic), php-intl:i386 (1:7.2+60ubuntu1)
End-Date: 2020-01-31  10:34:36