0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「フォロワー限定公開」を画面だけでなくAPIと画像配信まで守る

0
Posted at

はじめに

EO PLACE(エオプレイス)は、FF14のプレイヤー店舗やハウジングを写真から探し、保存し、ゲーム内で訪問できる個人開発のWebサービスです。

店舗とプロフィールに次の公開範囲を追加しました。

  • 公開:検索や一覧にも表示する
  • URL限定:一覧には出さず、URLを知る人は見られる
  • フォロワー限定:本人と承認済みフォロワーだけ見られる
  • 下書き:所有者だけ見られる(店舗のみ)

UIで非表示にするだけなら簡単ですが、APIを直接呼ぶ、画像URLだけ開く、一覧APIの条件を変える、といった経路から漏れる可能性があります。

この記事では、公開範囲を「ページの機能」ではなく、ページ、API、画像、検索、キャッシュ、SEOを横断する認可要件として実装した記録をまとめます。

最初に状態と閲覧者を分ける

店舗の状態を単純化すると、次の組み合わせになります。

状態 未ログイン 一般ユーザー 承認済みフォロワー 所有者
公開・公開中
URL限定・公開中 ○(直リンク) ○(直リンク)
フォロワー限定・公開中 × ×
下書き × × ×
モデレーション非表示 × × × ○※

※管理画面で所有者へ状態を知らせるための扱いです。公開面には出しません。

ここで「限定公開」という言葉だけでは、URL限定なのかフォロワー限定なのか分かりません。DBでは値を明確に分けます。

alter table shops
  add column visibility text not null default 'public';

alter table shops
  add constraint shops_visibility_check
  check (visibility in ('public', 'unlisted', 'followers'));

下書きかどうかは別のpublished_atで表現します。公開範囲と公開状態を1列へ詰め込まなかったことで、条件が読みやすくなりました。

一覧と詳細ではunlistedの条件が違う

URL限定店舗は、詳細URLなら見せますが検索結果には出しません。この差をSQLに反映します。

一覧はpublicを基本とし、ログイン中なら閲覧可能なfollowersを追加します。

where published_at is not null
  and moderation_hidden_at is null
  and (
    visibility = 'public'
    or (
      visibility = 'followers'
      and (
        owner_uid = $viewer_uid
        or exists (
          select 1
          from profile_follows
          where follower_uid = $viewer_uid
            and followed_uid = shops.owner_uid
        )
      )
    )
  )

一方、slugを指定する詳細取得ではunlistedも許可します。

where slug = $slug
  and published_at is not null
  and moderation_hidden_at is null
  and (
    visibility in ('public', 'unlisted')
    or (
      visibility = 'followers'
      and (
        owner_uid = $viewer_uid
        or exists (
          select 1 from profile_follows
          where follower_uid = $viewer_uid
            and followed_uid = shops.owner_uid
        )
      )
    )
  )

「一覧取得後にNode.jsでfilterする」方式にはしていません。取得時点で不要な行をDBから出さないことで、別のレスポンス変換を追加したときの漏えいリスクも減らします。

鍵プロフィールではフォローと申請を分ける

プロフィールがfollowersの場合、フォローボタンを押しただけで閲覧可能にしてはいけません。承認前はprofile_follow_requests、承認後はprofile_followsへ保存します。

create table profile_follow_requests (
  follower_uid text not null,
  followed_uid text not null,
  created_at timestamptz not null default now(),
  primary key (follower_uid, followed_uid)
);

処理はトランザクション内で対象プロフィールをロックし、公開範囲と既存フォローを確認して分岐します。

return sql.begin(async (tx) => {
  const target = await findTargetForUpdate(tx, publicId);
  if (!target || target.uid === followerUid) return;

  const alreadyFollowing = await hasFollow(tx, followerUid, target.uid);

  if (target.visibility === "followers" && !alreadyFollowing) {
    await createFollowRequest(tx, followerUid, target.uid);
    return { followed: false, requested: true };
  }

  await createFollow(tx, followerUid, target.uid);
  return { followed: true, requested: false };
});

承認処理では申請をフォローへ移し、申請時刻を引き継ぎます。拒否や申請取消は申請テーブルだけを削除します。

公開プロフィールへ戻した場合は、保留中の申請をフォローへ移す仕様にしました。公開範囲を変更した際に申請が永遠に残らないよう、状態遷移も決めておく必要があります。

APIを直接叩かれても同じ条件にする

ページでは、Server ComponentがログインユーザーのUIDを取得し、閲覧者付きのサービス関数を呼びます。

const user = await getCurrentUser();
const shop = await findShopBySlug(slug, user?.uid);
if (!shop) notFound();

APIでも同じです。

export async function GET(request: NextRequest, context: Context) {
  const user = await authenticateApiRequest(request);
  const shop = await findShopBySlug((await context.params).slug, user?.uid);

  if (!shop) {
    return apiError("not_found", "Shop not found", 404);
  }

  return Response.json(
    { data: shop },
    {
      headers:
        shop.visibility === "public" && !user
          ? publicCacheHeaders(60)
          : { "Cache-Control": "private, no-store" }
    }
  );
}

認可条件をReactコンポーネントへ閉じ込めず、ページとAPIが共用するDBサービス層に置きました。これならモバイルアプリがAPIを直接使っても同じ判定になります。

存在するが見られない店舗にも404を返します。403を返すと、第三者へ「そのslugの非公開店舗は存在する」と教えることになるためです。これはIDの推測を完全に防ぐものではありませんが、不要な列挙情報を増やさない判断です。

画像URLにも認可が必要

HTMLやJSONを守っても、/uploads/...をNginxから静的配信すれば画像URLだけで閲覧できます。そこで画像は公開ディレクトリへ置かず、/media/...のRoute Handlerを通します。

export async function GET(request: NextRequest, context: Context) {
  const key = (await context.params).key.join("/");

  if (!isAllowedStorageKey(key)) {
    return new Response("Not found", { status: 404 });
  }

  const user = await authenticateApiRequest(request);
  const access = await getShopMediaAccess(shopIdFrom(key), user?.uid);

  if (!access.allowed) {
    return new Response("Not found", { status: 404 });
  }

  const body = await readStoredImage(key);
  return new Response(body, {
    headers: {
      "Content-Type": "image/webp",
      "Cache-Control": access.restricted
        ? "private, no-store"
        : "public, max-age=31536000, immutable"
    }
  });
}

画像の認可関数も、店舗の所有者、公開状態、モデレーション状態、プロフィールの公開可否、承認済みフォローをまとめて確認します。

return {
  allowed:
    row.owner ||
    (row.published &&
      !row.moderationHidden &&
      row.ownerAccessible &&
      (row.visibility !== "followers" || row.followed)),
  restricted: !row.published || row.visibility !== "public"
};

ここを忘れると、ページを守ってもブラウザの履歴、共有URL、ログ等に残った画像パスから見えてしまいます。

キャッシュは認可とセットで設計する

公開データはキャッシュできますが、閲覧者によって結果が変わるレスポンスを共有キャッシュへ置いてはいけません。

現在の方針は次のとおりです。

対象 Cache-Control
未認証で取得した公開店舗一覧 短時間のpublic cache
ログインユーザー向け一覧 private, no-store
URL限定・フォロワー限定・下書き private, no-store
内容ハッシュ相当のUUIDを持つ公開画像 public, max-age=31536000, immutable
制限付き画像 private, no-store

認可付きレスポンスを正しく返しても、共有キャッシュに残せば次の利用者へ漏れる可能性があります。「返してよいか」と「保存してよいか」は同時に決めます。

SEO経路からも外す

URL限定とフォロワー限定にはnoindex, nofollowを設定し、サイトマップへはpublicだけを載せます。

export async function generateMetadata({ params }: Props) {
  const shop = await loadShop(params);
  return {
    title: shop.name,
    robots:
      shop.visibility !== "public"
        ? { index: false, follow: false }
        : undefined
  };
}
const publicShops = shops.filter((shop) => shop.visibility === "public");

noindexはアクセス制御ではありません。検索エンジンへ掲載しない意思表示として使い、実際の保護はサーバー認可で行います。

所有者だけの管理画面は別の取得関数にする

公開詳細用のfindShopBySlug()へ下書きを混ぜると、条件が複雑になります。所有店舗の管理画面では、UIDとslugの両方を条件にした専用関数を使います。

select ...
from shops
where owner_uid = $current_uid
  and slug = $slug
limit 1

公開ページでも本人なら下書きを見せたい場合は、「所有者向け取得へ無条件にフォールバック」するのではなく、認証済みUIDで所有権を確認した場合だけ取得します。編集ボタンも同じ所有権判定の結果に基づいて表示します。

確認した経路

実装後は、UIだけでなく次の組み合わせを確認しました。

  • 未ログインで公開、URL限定、フォロワー限定、下書きの詳細URL
  • 一般ユーザーと承認済みフォロワーで同じURL
  • 所有者による下書き表示と編集
  • 一覧APIと詳細APIの直接呼び出し
  • 画像URLの直接アクセス
  • フォロー申請前、申請中、承認後、解除後
  • 公開範囲変更後の申請状態
  • サイトマップへの掲載有無
  • 制限付きレスポンスのCache-Control

特に「フォロー申請中」は承認済みと混同しやすいため、独立したテスト状態として扱う必要があります。

実装して分かったこと

  • 公開範囲はUI属性ではなくデータアクセス規則
  • 一覧、詳細、画像では許可条件が少しずつ異なる
  • URL限定とフォロワー限定を同じ値にしない
  • フォロー申請と承認済みフォローを別テーブルで表す
  • APIは画面を経由しなくても同じ認可を行う
  • 見られない対象は404にそろえると存在情報を減らせる
  • 非公開レスポンスを共有キャッシュへ保存しない
  • noindexは認可の代わりにならない

限定公開を追加すると、単一ページの条件分岐では終わりません。最初に閲覧表を作り、DB取得、API、画像、キャッシュ、SEOへ同じ規則を展開したことで、「APIを直接叩いたら見える」という穴を避けやすくなりました。

参考資料

0
0
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
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?