0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「自分の投稿だけ編集できる」を Cloudflare D1 と Hono で実装する

0
Posted at

近況共有アプリで、投稿後に近況を編集できる機能を入れました。

ただし、重要な条件があります。

入力した本人だけが、自分の投稿を編集できること。

他の人の投稿を編集できてはいけません。

この記事では、Cloudflare Workers + Hono + D1 で「自分の投稿だけ編集できる」仕組みをどう設計したかをまとめます。

前提

このアプリでは、閲覧はURL共有された人なら誰でもできます。

一方で、投稿や編集はログインが必要です。

閲覧: 認証不要
投稿: 認証必要
編集: 認証必要 + 自分の投稿のみ

つまり、フロントで編集ボタンを隠すだけでは不十分で、API側で必ず所有者チェックをする必要があります。

DB設計

Phase1 では groupsclassmates だけで、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クエリの条件で本人投稿だけに絞るのが一番大事でした。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?