9. 実践アプリ:簡易CMS(日記投稿アプリ)を作ろう
この章では、ここまでに扱ったフォーム処理、HTML出力、ODBC、セッション管理を組み合わせて、小さな日記投稿アプリを設計します。
目的は、完成度の高いCMSを作ることではありません。CGIプログラムを複数に分け、リクエスト、認証、DB保存、一覧表示をどのようにつなげるかを確認することです。
この章で学ぶこと
- 小さなWebアプリの機能要件を整理する
- 日記投稿用のテーブルを設計する
- ログイン必須の投稿フォームを作る
- POSTされた値を検証してDBに保存する
- 公開投稿と自分の非公開投稿を一覧表示する
- SQLとHTML出力で安全な境界を意識する
作るもの
この章で作る日記投稿アプリは、次の機能を持ちます。
- ログイン済みユーザーだけが日記を投稿できる
- 投稿にはタイトル、本文、公開設定を持たせる
- 公開投稿は誰でも閲覧できる
- 非公開投稿は投稿者本人だけが閲覧できる
- 投稿一覧は新しいものから表示する
編集と削除は、この章では扱いません。まずは「投稿する」「保存する」「表示する」の流れを完成させます。
全体構成
CGIプログラムを次のように分けます。
| ファイル | 役割 |
|---|---|
diary_form.cgi |
投稿フォームを表示する |
diary_submit.cgi |
POSTを受け取り、DBに保存する |
diary_list.cgi |
日記一覧を表示する |
login.cgi |
ログイン処理を行う |
logout.cgi |
ログアウト処理を行う |
第8章のセッション処理を使い、投稿時にはログインユーザーIDを取得できる前提で進めます。第8章の最小例ではユーザー名を保存しましたが、この章のアプリではセッション情報に user_id も保存するように拡張します。
int current_user_id(void);
int current_username(char *out, size_t out_size);
未ログインの場合、current_user_id() は 0 を返すものとします。実際には第8章のセッションファイルに user_id を保存して読み取る実装にします。
テーブル設計
日記投稿を保存するテーブルです。
CREATE TABLE diary (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
is_public TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_diary_user_id (user_id),
INDEX idx_diary_public_created (is_public, created_at)
);
user_id は投稿者を表します。ユーザーテーブルを別に用意する場合は、外部キーを設定してもよいです。
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL
);
この章では、ユーザー登録やパスワードハッシュの作成までは扱いません。認証済みユーザーのIDがセッションから取れる前提で進めます。
投稿フォーム
投稿フォームはログイン済みユーザーだけに表示します。未ログインならログインページへリダイレクトします。
int main(void) {
int user_id = current_user_id();
if (user_id <= 0) {
printf("Status: 302 Found\r\n");
printf("Location: /login.html\r\n\r\n");
return 0;
}
printf("Content-Type: text/html; charset=UTF-8\r\n\r\n");
printf("<!DOCTYPE html>\n");
printf("<html lang=\"ja\">\n");
printf("<head><meta charset=\"UTF-8\"><title>日記を書く</title></head>\n");
printf("<body>\n");
printf("<h1>日記を書く</h1>\n");
printf("<form action=\"/cgi-bin/diary_submit.cgi\" method=\"post\">\n");
printf("<p><label>タイトル<br><input type=\"text\" name=\"title\" maxlength=\"255\" required></label></p>\n");
printf("<p><label>本文<br><textarea name=\"body\" rows=\"10\" cols=\"60\" required></textarea></label></p>\n");
printf("<p>公開設定<br>");
printf("<label><input type=\"radio\" name=\"is_public\" value=\"1\" checked>公開</label>\n");
printf("<label><input type=\"radio\" name=\"is_public\" value=\"0\">非公開</label></p>\n");
printf("<p><button type=\"submit\">投稿する</button></p>\n");
printf("</form>\n");
printf("</body></html>\n");
return 0;
}
HTMLの required や maxlength は入力補助です。サーバ側でも必ず検証します。
POSTデータを検証する
diary_submit.cgi では、第4章と第5章の処理を使ってPOST本文を読み、フォーム値を取り出します。
ここでは、次の関数がある前提でコード片を示します。
int read_post_body(char **out);
int parse_form(char *input, FormItem *items, int max_items);
const char *form_get(FormItem *items, int count, const char *key);
void free_form_items(FormItem *items, int count);
入力検証の例です。
#define MAX_FORM_ITEMS 20
#define MAX_TITLE_LEN 255
#define MAX_BODY_LEN 10000
int validate_diary_input(
const char *title,
const char *body,
const char *is_public
) {
if (title == NULL || body == NULL || is_public == NULL) {
return 0;
}
if (title[0] == '\0' || body[0] == '\0') {
return 0;
}
if (strlen(title) > MAX_TITLE_LEN || strlen(body) > MAX_BODY_LEN) {
return 0;
}
if (strcmp(is_public, "0") != 0 && strcmp(is_public, "1") != 0) {
return 0;
}
return 1;
}
POST受信と検証の流れです。
FormItem items[MAX_FORM_ITEMS];
char *post_body = NULL;
if (!read_post_body(&post_body)) {
render_error("POST body is invalid.");
return 1;
}
int count = parse_form(post_body, items, MAX_FORM_ITEMS);
const char *title = form_get(items, count, "title");
const char *body = form_get(items, count, "body");
const char *is_public_text = form_get(items, count, "is_public");
if (!validate_diary_input(title, body, is_public_text)) {
free_form_items(items, count);
free(post_body);
render_error("Input is invalid.");
return 0;
}
受け取った値は、HTMLにもSQLにもそのまま埋め込みません。HTMLに出すときはHTMLエスケープ、SQLに渡すときはプレースホルダを使います。
DBに保存する
投稿保存では、SQL文字列を連結せず、SQLPrepare() と SQLBindParameter() を使います。
SQLCHAR sql[] =
"INSERT INTO diary (user_id, title, body, is_public) "
"VALUES (?, ?, ?, ?)";
SQLINTEGER user_id_value = user_id;
SQLINTEGER public_value = strcmp(is_public_text, "1") == 0 ? 1 : 0;
SQLLEN user_id_ind = 0;
SQLLEN title_ind = SQL_NTS;
SQLLEN body_ind = SQL_NTS;
SQLLEN public_ind = 0;
ret = SQLPrepare(stmt, sql, SQL_NTS);
if (!odbc_succeeded(ret)) {
render_error("Failed to prepare SQL.");
goto cleanup;
}
SQLBindParameter(
stmt,
1,
SQL_PARAM_INPUT,
SQL_C_SLONG,
SQL_INTEGER,
0,
0,
&user_id_value,
0,
&user_id_ind
);
SQLBindParameter(
stmt,
2,
SQL_PARAM_INPUT,
SQL_C_CHAR,
SQL_VARCHAR,
255,
0,
(SQLPOINTER)title,
0,
&title_ind
);
SQLBindParameter(
stmt,
3,
SQL_PARAM_INPUT,
SQL_C_CHAR,
SQL_LONGVARCHAR,
0,
0,
(SQLPOINTER)body,
0,
&body_ind
);
SQLBindParameter(
stmt,
4,
SQL_PARAM_INPUT,
SQL_C_SLONG,
SQL_INTEGER,
0,
0,
&public_value,
0,
&public_ind
);
ret = SQLExecute(stmt);
if (!odbc_succeeded(ret)) {
render_error("Failed to save diary.");
goto cleanup;
}
SQLBindParameter() 自体の戻り値も、本来はそれぞれ確認します。本文では流れを見やすくするため、エラーチェックを一部省略しています。
保存に成功したら、一覧ページへリダイレクトします。
printf("Status: 302 Found\r\n");
printf("Location: /cgi-bin/diary_list.cgi\r\n\r\n");
一覧表示の条件
日記一覧では、次の条件で表示対象を決めます。
- 公開投稿は誰でも見られる
- 非公開投稿は投稿者本人だけが見られる
ログインしている場合は、次の条件になります。
SELECT id, title, body, is_public, created_at
FROM diary
WHERE is_public = 1 OR user_id = ?
ORDER BY created_at DESC
未ログインの場合は、公開投稿だけを表示します。
SELECT id, title, body, is_public, created_at
FROM diary
WHERE is_public = 1
ORDER BY created_at DESC
ログイン中のユーザーIDをSQLに埋め込む場合も、文字列連結ではなくプレースホルダで渡します。
一覧HTMLを出力する
DBから取得したタイトルや本文は、HTMLに出力する前にエスケープします。
printf("Content-Type: text/html; charset=UTF-8\r\n\r\n");
printf("<!DOCTYPE html>\n");
printf("<html lang=\"ja\"><body>\n");
printf("<h1>日記一覧</h1>\n");
while ((ret = SQLFetch(stmt)) != SQL_NO_DATA) {
SQLGetData(stmt, 2, SQL_C_CHAR, title, sizeof(title), &title_ind);
SQLGetData(stmt, 3, SQL_C_CHAR, body, sizeof(body), &body_ind);
SQLGetData(stmt, 4, SQL_C_SLONG, &is_public, 0, &public_ind);
SQLGetData(stmt, 5, SQL_C_CHAR, created_at, sizeof(created_at), &created_ind);
printf("<article>\n");
printf("<h2>");
html_escape_print(stdout, (const char *)title);
printf("</h2>\n");
printf("<p>");
html_escape_print(stdout, (const char *)body);
printf("</p>\n");
printf("<p>");
html_escape_print(stdout, (const char *)created_at);
printf(" / ");
printf("%s", is_public ? "公開" : "非公開");
printf("</p>\n");
printf("</article>\n");
}
printf("</body></html>\n");
このコード片では、title、body、created_at、is_public、各インジケータ変数が事前に宣言されている前提です。実際のコードでは SQLGetData() の戻り値とNULL値も確認します。
投稿処理の全体フロー
diary_submit.cgi の処理を整理すると、次の順序になります。
ログイン状態を確認する
POST本文を読む
フォーム値をパースする
入力値を検証する
ODBCでDBに接続する
INSERT文をprepareする
パラメータをbindする
SQLExecuteする
後始末する
一覧ページへリダイレクトする
この順序を守ると、認証、入力、DB、出力の責任が混ざりにくくなります。
この章で省略したこと
簡易CMSとして自然に欲しくなる機能はいくつもありますが、この章では扱いません。
- 投稿の編集
- 投稿の削除
- ページング
- CSRF対策
- 画像アップロード
- 管理者権限
- テンプレートファイル化
特にCSRF対策は、ログイン済みユーザーにPOST操作をさせるアプリでは重要です。実用に近づける場合は、セッションにCSRFトークンを保存し、フォーム送信時に照合する仕組みを追加します。
小まとめ
- 簡易CMSは、フォーム、セッション、DB、HTML出力の組み合わせで作れます。
- 投稿処理はログイン必須にし、投稿者の
user_idをDBに保存します。 - SQLにはユーザー入力を直接連結せず、プレースホルダで渡します。
- 一覧表示では、公開投稿と自分の投稿だけを取得します。
- DBから取り出した文字列も、HTML出力時にはエスケープします。
次章では、この日記データをHTMLではなくJSONとして返し、簡単なAPIとして扱う方法に進みます。