近況共有アプリで、投稿後に近況を編集できる機能を入れました。
ただし、重要な条件があります。
入力した本人だけが、自分の投稿を編集できること。
他の人の投稿を編集できてはいけません。
この記事では、Cloudflare Workers + Hono + D1 で「自分の投稿だけ編集できる」仕組みをどう設計したかをまとめます。
前提
このアプリでは、閲覧はURL共有された人なら誰でもできます。
一方で、投稿や編集はログインが必要です。
閲覧: 認証不要
投稿: 認証必要
編集: 認証必要 + 自分の投稿のみ
つまり、フロントで編集ボタンを隠すだけでは不十分で、API側で必ず所有者チェックをする必要があります。
DB設計
Phase1 では groups と classmates だけで、URL共有された人が投稿できる形にしていました。
今回は「ログインした本人だけが編集できる」を追加するため、既存の投稿アプリに次の差分を足しました。
追加したもの
- users
- auth_identities
- auth_sessions
- groups.owner_user_id
- classmates.author_user_id
- classmates(group_id, author_user_id) の unique index
まず、アプリ内ユーザーを表す users を追加します。
export const users = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
displayName: text('display_name').notNull().default(''),
avatarUrl: text('avatar_url').notNull().default(''),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
})
LINE のユーザーIDは users に直接持たせず、外部認証IDとして auth_identities に分けています。
今後 LINE 以外のログイン方法を追加する場合でも、アプリ内ユーザーと外部IDの対応を扱いやすくするためです。
export const authIdentities = sqliteTable(
'auth_identities',
{
id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id')
.notNull()
.references(() => users.id),
provider: text('provider', { enum: ['line'] }).notNull(),
providerUserId: text('provider_user_id').notNull(),
email: text('email').notNull().default(''),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
},
(table) => [
uniqueIndex('auth_identities_provider_user_unique').on(
table.provider,
table.providerUserId,
),
],
)
セッションは auth_sessions に保存します。Cookie に入れるセッショントークンそのものではなく、ハッシュをDBに保存する形です。
export const authSessions = sqliteTable('auth_sessions', {
id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id')
.notNull()
.references(() => users.id),
sessionTokenHash: text('session_token_hash').notNull().unique(),
csrfTokenHash: text('csrf_token_hash').notNull().default(''),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
})
投稿テーブル classmates は、Phase1 のテーブルに authorUserId を追加しました。
追加した部分だけ抜粋します。
export const classmates = sqliteTable('classmates', {
// 既存カラムは省略
authorUserId: integer('author_user_id').references(() => users.id),
}, (table) => [
uniqueIndex('classmates_group_author_unique').on(
table.groupId,
table.authorUserId,
),
])
ポイントは authorUserId です。
これによって、投稿ごとに「誰が作ったか」が分かります。
また、groupId + authorUserId に unique index を張っています。
uniqueIndex('classmates_group_author_unique').on(
table.groupId,
table.authorUserId,
)
この制約により、同じグループに同じユーザーが何度も投稿するのを防ぎやすくなります。
なお、既存投稿に authorUserId が入っていない場合、その投稿を後から安全に本人へ紐づけることはできません。
今回は安全側に倒して、既存投稿の編集は一旦対象外にしました。
投稿作成時に authorUserId を入れる
投稿作成APIでは、まずログイン必須にします。
app.post('/api/groups/:slug/classmates', async (c) => {
const session = await requireSession(c)
await verifyCsrf(c, session)
requireSession でログインユーザーを取得し、session.user.id を投稿の authorUserId に入れます。
const classmate = await db
.insert(classmates)
.values({
groupId: group.id,
authorUserId: session.user.id,
name: input.name,
nickname: input.nickname,
currentLocation: input.currentLocation,
job: input.job,
comment: input.comment,
snsUrl: input.snsUrl,
visibility: 'public',
status: 'published',
createdAt: now,
updatedAt: now,
})
.returning()
.get()
この時点で、投稿とログインユーザーが結びつきます。
同じグループへの二重投稿を防ぐ
作成前に、同じグループに自分の投稿があるか確認します。
const existingClassmate = await db
.select({ id: classmates.id })
.from(classmates)
.where(
and(
eq(classmates.groupId, group.id),
eq(classmates.authorUserId, session.user.id),
ne(classmates.status, 'deleted'),
),
)
.get()
if (existingClassmate) {
return c.json(
{
message: 'このグループにはすでに投稿済みです',
classmate: { id: existingClassmate.id },
},
409,
)
}
この 409 をフロントで受けて、既存投稿の編集画面に誘導できます。
自分の投稿一覧API
フロントで「このカードは自分の投稿か?」を判断するため、自分の投稿一覧APIを用意しました。
未ログインの場合はエラーにせず、空配列を返します。
app.get('/api/me/groups/:slug/classmates', async (c) => {
const session = await getCurrentSession(c)
if (!session) {
c.header('Cache-Control', 'no-store')
return c.json({ classmates: [] })
}
const db = drizzle(c.env.DB)
const slug = c.req.param('slug')
const group = await db.select().from(groups).where(eq(groups.slug, slug)).get()
if (!group) {
throw new HTTPException(404, { message: 'グループが見つかりません' })
}
const rows = await db
.select({
id: classmates.id,
createdAt: classmates.createdAt,
updatedAt: classmates.updatedAt,
})
.from(classmates)
.where(
and(
eq(classmates.groupId, group.id),
eq(classmates.authorUserId, session.user.id),
ne(classmates.status, 'deleted'),
),
)
.orderBy(desc(classmates.createdAt))
c.header('Cache-Control', 'no-store')
return c.json({ classmates: rows })
})
未ログインでも feed は見られる設計なので、ここで 401 にしないのがポイントです。
編集取得APIも authorUserId で絞る
編集画面を開くAPIでは、投稿IDだけで取得しません。
必ず以下の条件を入れます。
- 投稿IDが一致する
- グループIDが一致する
-
authorUserIdがログインユーザーIDと一致する - 削除済みではない
app.get('/api/me/groups/:slug/classmates/:id', async (c) => {
const session = await requireSession(c)
const { group, classmateId } = await getGroupAndClassmateId(c)
const db = drizzle(c.env.DB)
const classmate = await db
.select({
id: classmates.id,
name: classmates.name,
nickname: classmates.nickname,
currentLocation: classmates.currentLocation,
job: classmates.job,
comment: classmates.comment,
snsUrl: classmates.snsUrl,
visibility: classmates.visibility,
createdAt: classmates.createdAt,
updatedAt: classmates.updatedAt,
})
.from(classmates)
.where(
and(
eq(classmates.id, classmateId),
eq(classmates.groupId, group.id),
eq(classmates.authorUserId, session.user.id),
ne(classmates.status, 'deleted'),
),
)
.get()
if (!classmate) {
throw new HTTPException(404, { message: 'この投稿は編集できません' })
}
c.header('Cache-Control', 'no-store')
return c.json({ classmate })
})
他人の投稿IDをURLに直接入れても、この where に引っかからないため取得できません。
更新APIも authorUserId で絞る
更新APIも同じです。
app.patch('/api/me/groups/:slug/classmates/:id', async (c) => {
const session = await requireSession(c)
await verifyCsrf(c, session)
const { group, classmateId } = await getGroupAndClassmateId(c)
const db = drizzle(c.env.DB)
const input = classmateInputSchema.parse(await c.req.json())
const now = new Date()
const classmate = await db
.update(classmates)
.set({
name: input.name,
nickname: input.nickname,
currentLocation: input.currentLocation,
job: input.job,
comment: input.comment,
snsUrl: input.snsUrl,
visibility: 'public',
updatedAt: now,
})
.where(
and(
eq(classmates.id, classmateId),
eq(classmates.groupId, group.id),
eq(classmates.authorUserId, session.user.id),
ne(classmates.status, 'deleted'),
),
)
.returning({
id: classmates.id,
updatedAt: classmates.updatedAt,
})
.get()
if (!classmate) {
throw new HTTPException(404, { message: 'この投稿は編集できません' })
}
c.header('Cache-Control', 'no-store')
return c.json({ classmate })
})
ここでも、フロントの表示状態には依存しません。
API側の where 条件が本体です。
フロントでは自分の投稿だけ編集ボタンを出す
API側で守っているとはいえ、UI上は自分の投稿だけ編集ボタンを出します。
function FeedPage() {
const { slug } = useSlugParam();
const { classmates, error, isLoading, myClassmates, reload } =
useClassmates(slug);
const [order, setOrder] = useState<"new" | "old">("new");
const [avatarByClassmateId] = useState(() => getClassmateAvatarMap());
const myClassmateIds = useMemo(() => {
return new Set(myClassmates.map((classmate) => classmate.id));
}, [myClassmates]);
return (
<section className="space-y-4 pb-24 pt-4">
{sortedClassmates.map((classmate) => (
<ClassmateCard
avatarUrl={avatarByClassmateId[String(classmate.id)] || null}
canEdit={myClassmateIds.has(classmate.id)}
classmate={classmate}
key={classmate.id}
slug={slug}
/>
))}
</section>
);
}
カード側では、canEdit が true のときだけメニューを表示します。
{canEdit ? (
<div className="relative shrink-0">
<button
aria-expanded={isMenuOpen}
aria-label="投稿メニューを開く"
className="grid size-9 place-items-center rounded-full text-stone-600 hover:bg-stone-100"
onClick={() => setIsMenuOpen((value) => !value)}
type="button"
>
<Ellipsis size={21} />
</button>
{isMenuOpen ? (
<div className="absolute right-0 top-10 z-10 w-32 overflow-hidden rounded-md border border-stone-200 bg-white py-1 text-sm font-bold shadow-lg">
<Link
className="flex h-11 items-center gap-2 px-4 text-stone-800 hover:bg-stone-50"
onClick={() => setIsMenuOpen(false)}
to={`/g/${slug}/classmates/${classmate.id}/edit`}
>
<ClipboardPen size={17} />
編集
</Link>
</div>
) : null}
</div>
) : null}
右上の「...」メニューにしたのは、将来的に削除ボタンも追加できるようにするためです。
既存投稿をどう扱うか
この設計では、authorUserId が入っている投稿だけが編集対象になります。
すでに存在していた投稿に authorUserId がない場合、そのままでは本人判定ができません。
今回は、既存投稿の編集対応は一旦諦めました。
理由は、後から本人を正しく紐づけるには、何らかの本人確認や管理者による紐づけが必要になるためです。
まとめ
「自分の投稿だけ編集できる」を実装するときのポイントは、フロントではなくAPI側です。
今回の実装では以下を意識しました。
- 投稿作成時に
authorUserIdを保存する - 投稿取得・更新時に
authorUserId = session.user.idで絞る - フロントの編集ボタン表示は補助として扱う
- 書き込みAPIには認証と CSRF チェックを入れる
- 既存投稿は無理に編集可能にしない
権限制御は「UIで隠す」だけでは足りません。
最終的には、DBクエリの条件で本人投稿だけに絞るのが一番大事でした。