9. 実践アプリ:簡易CMS(日記投稿アプリ)を作ろう

この章では、ここまでに扱ったフォーム処理、HTML出力、ODBC、セッション管理を組み合わせて、小さな日記投稿アプリを設計します。

目的は、完成度の高いCMSを作ることではありません。CGIプログラムを複数に分け、リクエスト、認証、DB保存、一覧表示をどのようにつなげるかを確認することです。


この章で学ぶこと


作るもの

この章で作る日記投稿アプリは、次の機能を持ちます。

編集と削除は、この章では扱いません。まずは「投稿する」「保存する」「表示する」の流れを完成させます。


全体構成

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の requiredmaxlength は入力補助です。サーバ側でも必ず検証します。


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");

このコード片では、titlebodycreated_atis_public、各インジケータ変数が事前に宣言されている前提です。実際のコードでは SQLGetData() の戻り値とNULL値も確認します。


投稿処理の全体フロー

diary_submit.cgi の処理を整理すると、次の順序になります。

ログイン状態を確認する
POST本文を読む
フォーム値をパースする
入力値を検証する
ODBCでDBに接続する
INSERT文をprepareする
パラメータをbindする
SQLExecuteする
後始末する
一覧ページへリダイレクトする

この順序を守ると、認証、入力、DB、出力の責任が混ざりにくくなります。


この章で省略したこと

簡易CMSとして自然に欲しくなる機能はいくつもありますが、この章では扱いません。

特にCSRF対策は、ログイン済みユーザーにPOST操作をさせるアプリでは重要です。実用に近づける場合は、セッションにCSRFトークンを保存し、フォーム送信時に照合する仕組みを追加します。


小まとめ

次章では、この日記データをHTMLではなくJSONとして返し、簡単なAPIとして扱う方法に進みます。