Perl で簡易チャット CGI を作った

以前、Perl を改めて勉強し直した時に、1ファイルで動く簡易チャット CGI を作った。

#!/usr/bin/perl


# ================================================================================
# Perl Chat
# 
# FIXME : ログファイル追記時のファイルロック処理が未実装
# ================================================================================

use strict;
use warnings;
use utf8;  # このファイルのエンコーディングを示す
use open ':encoding(UTF-8)';  # ファイル入出力用・open() の第2引数で示すのと同じ (:utf8 と書くとエンコードチェックしない)
use Fcntl;  # sysopen() を使うために使用
use FindBin;  # CGI ファイルの名前を割り出すために使用

binmode(STDOUT, ':encoding(UTF-8)');  # 標準入出力向け・「Wide character in print」回避用


# ================================================================================
# 設定事項
# ================================================================================

# この CGI ファイルの名前 (投稿一覧取得・投稿処理時の非同期通信先 URL として使用する)
my $thisFile = './perl-chat.cgi';

# 投稿ログファイルの名前
my $logFileName = './perl-chat.log';

# 保持する投稿件数
my $maxPosts = 50;


# ================================================================================
# メイン処理
# ================================================================================

# 設定事項が正しいかチェックする・問題があれば異常終了する
my $invalidMessage = checkSettings();
if(!$invalidMessage eq '') {
  print("Content-type: text/html; charset=UTF-8\n\n$invalidMessage");
  exit 1;
}

# リクエストを判別し処理する
if($ENV{'REQUEST_METHOD'} eq 'POST') {
  # POST : 投稿時
  postMessage();
}
else {
  # クエリをパースする (1回のリクエスト処理中に複数回呼ばないこと)
  my %queries = parseQueries();
  
  if($queries{'mode'} && $queries{'mode'} eq 'get-all') {
    # GET : 投稿一覧取得
    getAllPosts();
  }
  else {
    # 画面初期表示
    viewPage();
  }
}


# ================================================================================
# 設定項目チェック
# ================================================================================

# 設定項目チェック・ログファイルがない場合は新規生成を試行する
# 
# @return 問題があれば文字列を返す・問題がなければ空文字を返す
sub checkSettings {
  # ファイル名が指定されていない場合、指定されたパスにファイルが存在しない場合は自分で割り出してみる
  if($thisFile eq '' || ! -f $thisFile) {
    $thisFile = "./$FindBin::Script";
  }
  if(! -f $thisFile) {
    return 'この CGI ファイル名として指定されたパスにファイルがありません';
  }
  
  if($logFileName eq '') {
    return 'ログファイル名が未指定です';
  }
  if($maxPosts < 1) {
    return '最大投稿数は 1 以上を指定してください';
  }
  
  # ログファイルがなければ生成を試みて、生成できなければ異常終了する
  my $logFile;
  eval {
    sysopen($logFile, $logFileName, O_CREAT, 0666) or die($!);
  };
  if($@) {
    return 'ログファイルの生成・権限付与に失敗しました';
  }
  close($logFile);
  
  # 正常終了
  return '';
}


# ================================================================================
# 画面初期表示
# ================================================================================

# 画面を初期表示する
sub viewPage {
  printHtmlHeader();
  
  print(<<'EOL');
<header id="header">
  <h1 id="title">Perl Chat</h1>
  <div id="reload-container"><button type="button" id="reload">再読込</button></div>
  <input type="text" id="name" name="name" value="" placeholder="名前" maxlength="10">
  <input type="text" id="text" name="text" value="" placeholder="本文" maxlength="200">
  <button type="button" id="submit">投稿</button>
</header>
<div id="posts">
  <div id="loading">読込中…</div>
</div>
EOL
  
  printHtmlFooter();
}

# HTML の body の開始タグまでを print() する
sub printHtmlHeader {
  print(<<'EOL');
Content-type: text/html; charset=UTF-8
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8">
    <title>Test</title>
    <style>
EOL
  printStyles();
  print(<<'EOL');
    </style>
    <script>
EOL
  printScripts();
  print(<<'EOL');
    </script>
  </head>
  <body>
EOL
}

# style 要素の中身を print() する
sub printStyles {
  print(<<'EOL');
*,
::before,
::after {
  box-sizing: border-box;
}
body {
  margin: 1rem;
  word-break: break-all;
  overflow-y: scroll;
  background: #fff;
}
#header {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 120px;
  display: grid;
  grid-template-areas: 'title title reload' 'name text submit';
  grid-template-columns: 10rem 1fr 5rem;
  grid-template-rows: auto auto;
  grid-gap: 1rem;
  padding: 1rem;
  background: #fff;
}
#title {
  grid-area: title;
  margin: 0;
}
#reload-container {
  grid-area: reload;
}
#name {
  grid-area: name;
}
#text {
  grid-area: text;
}
#submit {
  grid-area: submit;
}
#reload,
#name,
#text,
#submit {
  width: 100%;
  border: 1px solid #ccc;
  border-radius: 4px;
  padding: .25rem .5rem;
}
#posts {
  padding-top: 120px;
}
.post {
  display: grid;
  grid-template-columns: 10rem 1fr auto;
  grid-gap: 1rem;
  margin-bottom: 1rem;
  border-bottom: 1px solid #ccc;
  padding-bottom: 1rem;
}
.name {
  font-weight: bold;
}
.date-time {
  color: #999;
  font-size: .8rem;
  text-align: right;
  word-break: normal;
  white-space: nowrap;
}
#loading {
  color: #999;
}
#not-found {
  color: #00f;
}
#error {
  color: #f00;
}
EOL
}

# script 要素の中身を print() する
sub printScripts {
  # 設定項目の変数を埋め込むためダブルクォートを使用
  print(<<"EOL");
// イベント定義
document.addEventListener('DOMContentLoaded', () => {
  // 初期表示時の処理
  onInit();
  onGetAll();
  
  // 「再読込」ボタン押下時の処理
  document.getElementById('reload').addEventListener('click', onGetAll);
  
  // 「投稿」ボタン押下時の処理
  document.getElementById('submit').addEventListener('click', onSubmit);
});
/**
 * 初期表示時の処理
 */
function onInit() {
  // LocalStorage から名前を取り出す
  const name = localStorage.getItem('name');
  if(name == null || name === '') return;
  // テキストボックスに設定する
  const nameElem = document.getElementById('name');
  if(!nameElem) return;
  nameElem.value = name;
}
/**
 * 初期表示時および「再読込」ボタン押下時
 */
function onGetAll() {
  const postsElem = document.getElementById('posts');
  postsElem.innerHTML = '<div id="loading">読込中…</div>';
  
  // GET 通信する
  const xhr = new XMLHttpRequest();
  xhr.timeout = 5000;
  xhr.onreadystatechange = () => {
    if(xhr.readyState !== 4) return;
    
    let responseJson;
    try {
      responseJson = JSON.parse(xhr.responseText);
    }
    catch(error) {
      postsElem.innerHTML = '<div id="error">ログファイルの読み込みに失敗しました</div>';
      return console.error('After GET Parse Error', error);
    }
    
    if(responseJson.error) {
      postsElem.innerHTML = '<div id="error">ログファイルの読み込みに失敗しました</div>';
      return console.error('After GET Error Response', responseJson.error);
    }
    
    if(responseJson.posts.length === 0) {
      postsElem.innerHTML = '<div id="not-found">投稿がありません</div>';
      return;
    }
    
    // HTML を組み立てて投稿一覧に代入する
    postsElem.innerHTML = responseJson.posts.map((post) => {
      return `<div class="post"><div class="name">\${post.name}</div><div class="text">\${post.text}</div><div class="date-time">\${post.dateTime}</div></div>`;
    }).join('');
  };
  xhr.ontimeout = (error) => {
    console.error('GET Timeout', error);
  };
  xhr.open('GET', '$thisFile?mode=get-all');
  xhr.send();
}
/**
 * 「投稿」ボタン押下時の処理
 */
function onSubmit() {
  const name = document.getElementById('name');
  const text = document.getElementById('text');
  
  // 入力チェック
  if(isInvalidTextBoxValue(name)) return alert('名前を入力してください');
  if(isInvalidTextBoxValue(text)) return alert('本文を入力してください');
  
  // POST 送信する
  const xhr = new XMLHttpRequest();
  xhr.timeout = 5000;
  xhr.onreadystatechange = () => {
    if(xhr.readyState !== 4) return;
    
    // 投稿されたデータを受け取る
    let responseJson;
    try {
      responseJson = JSON.parse(xhr.responseText);
    }
    catch(error) {
      console.error('After POST Parse Error', error);
      return alert('投稿データの読み込みに失敗しました');
    }
    
    if(responseJson.error) {
      console.error('After POST Error Response', responseJson.error);
      return alert('投稿データの読み込みに失敗しました');
    }
    
    if(document.getElementById('error')) document.getElementById('error').remove();
    if(document.getElementById('not-found')) document.getElementById('not-found').remove();
    
    // 投稿一覧の先頭に追加する
    const postsElem = document.getElementById('posts');
    const post = `<div class="post"><div class="name">\${responseJson.name}</div><div class="text">\${responseJson.text}</div><div class="date-time">\${responseJson.dateTime}</div></div>`;
    postsElem.insertAdjacentHTML('afterbegin', post);
    
    // 投稿一覧の個数が最大個数を超えた場合は多い分を削除する
    let postsCount = document.querySelectorAll('.post').length;
    while(postsCount > $maxPosts) {
      postsElem.removeChild(postsElem.lastChild);
      postsCount = document.querySelectorAll('.post').length;
    }
    
    // 本文をリセットする
    text.value = '';
  };
  xhr.ontimeout = (error) => {
    console.error('POST Timeout', error);
  };
  xhr.open('POST', '$thisFile');
  xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
  xhr.send(encodeHtmlForm({
    name: name.value.trim(),
    text: text.value.trim(),
    dateTime: formatDateTime(new Date())
  }));
  
  // 名前を保存しておく
  localStorage.setItem('name', name.value.trim());
}
/**
 * 指定のテキストボックス要素に文字列が入力されているかどうかチェックする
 * 
 * \@param elem 要素
 * \@return 文字列が入力されていなければ true (エラー)
 */
function isInvalidTextBoxValue(elem) {
  return elem == null || elem.value == null || elem.value.trim() === '';
}
/**
 * 指定のオブジェクトを POST 送信用に URI エンコードする
 * 
 * \@param data Form 送信したいデータを格納した連想配列
 * \@return URI エンコードした文字列
 */
function encodeHtmlForm(data) {
  return Object.keys(data).map(key => `\${encodeURIComponent(key)}=\${encodeURIComponent(data[key])}`).join('&').replace(/%20/g, '+');
}
/**
 * 指定の Date オブジェクトを 'YYYY-MM-DD HH:mm:ss' 形式の文字列に変換して返す
 * 
 * \@param date Date オブジェクト
 * \@return 'YYYY-MM-DD HH:mm:ss' 形式の文字列
 */
function formatDateTime(date) {
  return date.getFullYear()
    + '-' + `0\${date.getMonth() + 1}`.slice(-2)
    + '-' + `0\${date.getDate()}`.slice(-2)
    + ' ' + `0\${date.getHours()}`.slice(-2)
    + ':' + `0\${date.getMinutes()}`.slice(-2)
    + ':' + `0\${date.getSeconds()}`.slice(-2);
}
EOL
}

# HTML の body の終了タグ以降を print() する
sub printHtmlFooter {
  print(<<'EOL');
  </body>
</html>
EOL
}


# ================================================================================
# GET : 投稿一覧取得時
# ================================================================================

# 投稿一覧を取得して JSON 形式で返す
sub getAllPosts {
  # ファイルハンドル
  my $logFile;
  # 読取専用で開く
  eval {
    open($logFile, '<', $logFileName) or die($!);
  };
  if($@) {
    chomp($@);
    print("Content-type: application/json\n\n{ \"error\": \"$@\" }");
    return;
  }
  my @lines = <$logFile>;
  
  # 配列を返す
  my $jsonStr = '{ "posts": [';
  if(@lines) {
    foreach my $line (@lines) {
      my ($name, $text, $dateTime) = split(/\t/, $line);
      # 行末の改行文字を消す
      chomp($dateTime);
      $jsonStr .= "{ \"name\": \"$name\", \"text\": \"$text\", \"dateTime\": \"$dateTime\" },";
    }
    chop($jsonStr);  # 最後のカンマを削る
  }
  $jsonStr .= ']}';
  
  print("Content-type: application/json\n\n$jsonStr");
}


# ================================================================================
# POST 送信時
# ================================================================================

# POST 送信されてきたデータを JSON 文字列に変換してレスポンスする
sub postMessage {
  # クエリをパースする (1回のリクエスト処理中に複数回呼ばないこと)
  my %queries = parseQueries();
  
  # ファイルハンドル
  my $logFile;
  
  # 元のデータを取得するため読取専用で開く
  eval {
    open($logFile, '<', $logFileName) or die($!);
  };
  if($@) {
    chomp($@);
    print("Content-type: application/json\n\n{ \"error\": \"$@\" }");
    return;
  }
  my @originalLines = <$logFile>;
  close($logFile);
  
  # 書込モードで開き直す
  eval {
    open($logFile, '>', $logFileName) or die($!);
  };
  if($@) {
    chomp($@);
    print("Content-type: application/json\n\n{ \"error\": \"$@\" }");
    return;
  }
  
  # 1行目に追記するデータを組み立てて書き込む
  my $name = convertSafeText($queries{'name'});
  my $text = convertSafeText($queries{'text'});
  my $dateTime = convertSafeText($queries{'dateTime'});
  my $post = "$name\t$text\t$dateTime\n";
  print($logFile $post);
  # 最大投稿件数を超えるデータはちぎって書き込む
  splice(@originalLines, $maxPosts - 1);
  print($logFile @originalLines);
  close($logFile);
  
  # 投稿されたデータをそのまま JSON で送り返す
  responseJson(%queries);
}

# リクエストデータを JSON に変換してレスポンスする
# 
# @param %queries パースされたクエリのハッシュ
sub responseJson {
  my (%queries) = @_;
  
  my $jsonStr = '{';
  if(%queries) {
    while(my ($key, $value) = each %queries) {
      $key   = convertSafeText($key);
      $value = convertSafeText($value);
      $jsonStr .= "\"$key\": \"$value\",";
    }
    chop($jsonStr);  # 最後のカンマを削る
  }
  $jsonStr .= '}';
  
  print("Content-type: application/json\n\n$jsonStr");
}


# ================================================================================
# ユーティリティ関数
# ================================================================================

# クエリ文字列を連想配列に変換する (GET・POST 対応)
#
# @return パースされたクエリのハッシュ
sub parseQueries {
  # 最終的にリクエスト情報をまとめる連想配列
  my %formData;
  
  # QueryString を取得する
  my $queryString;
  if($ENV{'REQUEST_METHOD'} eq 'POST') {
    read(STDIN, $queryString, $ENV{'CONTENT_LENGTH'});
  }
  else {
    $queryString = $ENV{'QUERY_STRING'};
  }
  
  # 'name=value' の配列に直す
  my @queryPairs = split('&', $queryString);
  foreach my $queryPair (@queryPairs) {
    my ($name, $value) = split('=', $queryPair);
    # パーセントエンコーディング文字列を戻す
    $value =~ tr/+/ /;
    $value =~ s/%([a-fA-F0-9][a-fA-F0-9])/pack('C', hex($1))/eg;
    # UTF-8 フラグを付ける (これをやらないとファイル書き込み時に文字化けする)
    utf8::decode($value);
    $formData{$name} = $value;
  }
  
  return %formData;
}

# HTML パースされないよう記号を置換する
# 
# @param $str 置換したい文字列
# @return 置換した文字列
sub convertSafeText {
  my ($str) = @_;
  $str =~ s/</&lt;/g;
  $str =~ s/>/&gt;/g;
  return $str;
}

投稿をファイルに書き込むようにしているのだが、ファイル書き込み時のロック機構を実装していないお粗末仕様。面倒臭くて飽きたのでココで終わり。