はじめに
どうも、わたしです。
先日、このサイトにいくつかの変更を加えました。
元々は、壁打ちブログのつもりで運営していたのですが、承認欲求がとうとう無視できない大きさになってしまいまして。
ちなみに、ここで話すソースコードは全てGitHub上に公開しているので、もし気になる人がいれば、読んでみてもらえると嬉しいです。
https://github.com/chan-mai
開発を始めた当初は、ただただコメントといいね機能を追加するだけの、簡単な改修のつもりでした。
ところが、いざ作り始めると「コメントは絶対に自分の目で見てから承認したい」「どうせなら管理画面もちゃんと作りたい」と、次から次へとやりたいことが溢れてきてしまい、その結果、そこそこ本格的なWebアプリケーションへと変貌を遂げました。
最初の動機を考えると、拍子抜けするほど大掛かりなものになってしまったな、というのが正直な感想です。
やったこと
今回の改修の始まりは、サイトに外との接点となるインタラクション機能を設けることでした。
まず手始めに、いいね機能に手を入れています。
仕組みは簡単なもので、ユーザーがハートを押すとAPIが叩かれ、記事のcontentId
やIPアドレスなんかが記録されます。
同時にlocalStorageにuuidが保存されるのですが、これはいいねの解除時に使うためだけのものであって、IPベースでの制限はしていないので、スマホとPCから同じ記事に…みたいなことも普通に出来てしまいます。
普通に欠陥な気もするのですが、ちゃんと実装する気力はなかったので仕様です。
そして、コメント機能。
だいたいコメントというのは常々、おぞましいほどに醜い言葉で溢れるのが大概で、私はそういうものを目にしたくないのです。
だから、どうしてもコメントを完全にコントロールする手段が必要でした。
その解決策が、投稿されたコメントを一つひとつ自分の目で確認し、手動で承認するという運用フローと、それを実現するための管理コンソールだったわけです。
ここからは、技術的な話をちょっとだけします。
コメント機能の裏側
まず、コメント機能そのものの話から。
ユーザーがコメントを投稿すると、POST /api/comment/[contentId]
というAPIが叩かれます。
バックエンドでは、ユーザー入力値の基本的なバリデーションと、お気持ち程度のTurnstileのtoken検証のみを行なっています。
投稿されたコメントは、まずデータベースにPENDING
ステータスで保存されます。
このステータス管理が肝で、PrismaのスキーマにCommentStatus
というenumを定義して、PENDING
、APPROVED
、REJECTED
の3つの状態を持たせました。
承認されるまでAPIの返り値を含めフロントには一切露出しない、完全な事前承認制です。
データベース
これらのデータを保存しているのが、CockroachDBという分散SQLデータベースです。
所謂NewSQLとかいうやつですね。
PostgreSQLと互換性がありつつ、スケーラビリティと耐障害性に優れている面白い子で、名前が気持ち悪いことを除けば結構好きです。
ORMにはPrismaを選びました。
「型安全でマイグレーションが簡単!」とは言うものの、最近のORMであれば大体は似たようなことができる気がします。
選定理由は単なる好みです。
名前かわいいし!
今回追加した主なモデルは、いいね用のFavorites
、コメント用のComments
、そして管理者情報を格納するAdminUser
と権限を管理するAdminPermission
。
// いいね
model Favorites {
id String @id @default(uuid(7))
contentId String @map("content_id")
userIp String @map("user_ip")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([contentId])
@@index([userIp])
@@map("favorites")
}
// コメントステータス
enum CommentStatus {
PENDING // 承認待ち
APPROVED // 承認
REJECTED // 拒否
}
// コメント
model Comments {
id String @id @default(uuid(7))
contentId String @map("content_id")
name String
comment String
userIp String @map("user_ip")
status CommentStatus @default(PENDING)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([contentId])
@@index([userIp])
@@index([status])
@@map("comments")
}
// 権限の種類
enum Permission {
COMMENT_VIEW // コメント閲覧
COMMENT_ADMIN // コメント管理
FAVORITE_VIEW // お気に入り閲覧
FAVORITE_ADMIN // お気に入り管理
ADMIN_USER_VIEW // 管理者ユーザー閲覧
ADMIN_USER_ADMIN // 管理者ユーザー管理
}
// 管理者ユーザー
model AdminUser {
id String @id @default(uuid(7))
githubUsername String @unique @map("github_username")
githubUserId BigInt @map("github_user_id")
displayName String? @map("display_name")
email String?
avatarUrl String? @map("avatar_url")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
permissions AdminPermission[]
@@index([githubUsername])
@@index([isActive])
@@map("admin_users")
}
// 管理者権限
model AdminPermission {
id String @id @default(uuid(7))
adminId String @map("admin_id")
permission Permission
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
admin AdminUser @relation(fields: [adminId], references: [id], onDelete: Cascade)
@@unique([adminId, permission])
@@index([adminId])
@@index([permission])
@@map("admin_permissions")
}
管理コンソール
この管理コンソールこそ、今回の改修で最も大きな作業となった代物です。
「コメントは絶対に自分の目で見てから承認したい」という要件が、この巨大な機能を生み出すきっかけでした。
最初は適当なBASIC認証でなんとかしようと思っていたのですが、「どうせならとログイン機能も…」と考えたのが運の尽きです。
認証には手軽なGitHub OAuthを採用しました。
ユーザーがログインするとGitHubの認証ページに飛び、認可が済むとコールバックURLに戻ってくる。
GitHubから受け取ったユーザー情報がAdminUser
テーブルに登録されていて、かつアクティブならセッションを発行する、という流れです。
セッション管理にはnuxt-auth-utils
を利用しました。
内部的にJWTを使い、いい感じにサーバーサイドでセッションを検証してくれます。
認証関連の処理はserver/utils/auth.ts
にまとめていて、特にrequireAdminSession
関数では、リクエストごとに管理者の存在とアクティブ状態をDBに確認しにいく割と厳格な権限確認を行っています。
Middlewareで未認証ユーザーをサインインページに弾いてくれるので、部外者が管理画面を覗くことはできません。
そして極め付けが、RBACの実装です。
このサイトの管理者は私一人しかいないのに、いくつかの細かい権限を定義しています。完全に過剰な機能ですね。
でも、この設計のおかげで、将来「この人にはコメントの承認だけ任せたい」なんてことが可能になりました。
各APIエンドポイントの冒頭でrequirePermission
関数を呼び出して権限をチェックし、フロント側でもComposableで、権限に応じた表示に切り替えています。
UIも割といい感じにできたので自慢がてら見せびらかしておきます。



さいごに
今回のサイト改修は、過去のちょっとした機能追加が嘘のように、大規模なものとなりました。
承認欲求という、どちらかといえばネガティブな感情から始まった作業でしたが、いざ手を動かし始めると、技術的な好奇心がすべてを上回ってしまった感じです。
満足したので安心して眠れます。
それでは。
コメント
まだコメントがありません
最初のコメントを投稿してみましょう!
コメントを投稿