iT邦幫忙

2024 iThome 鐵人賽

DAY 29
0
佛心分享-SideProject30

用 Sanity 跟 Nextjs 重寫個人部落格系列 第 29

Day 29 - Next.js 與 Sanity 的 Security 驗證

  • 分享至 

  • xImage
  •  

在要寫前一篇的後續:「Sanity Visual Editing 跟 Next.js 的 Security 驗證」時,發現自己前面寫得太開心,自己都沒有注意到安全性問題。Next.js 那端都忘了要使用 sanity 的 token 來保護安全性。


就先來開個倒車把前面的問題修復一下,在 Day 21 - Next.js 載入更多文章 中,我直接在 "use client" component 中使用了從 ./app/sanity/lib/client.ts 引入的 client 方法進行新文章的請求:

// ./app/components/PostsBlock.tsx

"use client";
import { client } from "@/app/sanity/lib/client";
import { BLOG_POSTS_QUERY } from "@/app/sanity/lib/queries";
// ...

export default function PostsBlock() {

	// ...
	
	async function fetchPosts(
	  lastPublishedAt = "4000-01-01",
	  title = "",
	  perPage = 5,
	) {
	  const newPosts = await client.fetch(BLOG_POSTS_QUERY, {
	    lastPublishedAt,
	    title,
	    perPage,
	  });

	  // ...

	}
  return (
	  // ...
  )
}

這樣確實是可以使用沒錯,但是這麼一來,Next.js 專案是怎麼向 Sanity 做出請求的方法都被別人看光了,曝光太多細節了,遇到有心人是可以對我的 Sanity 進行各種想要的查詢的。

要隱藏這些細節又保有文章搜尋功能的話可以建立一個 /api/posts API,每次要查詢文章都是對他發出請求,再由 API 對 Sanity 發出請求,藉此把請求細節藏在背後。

// ./app/api/posts/route.ts

import { NextRequest, NextResponse } from "next/server";
import { client } from "@/app/sanity/lib/client";
import { BLOG_POSTS_QUERY } from "@/app/sanity/lib/queries";

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const lastPublishedAt = searchParams.get("lastPublishedAt") || "2024-10-05";
  const title = searchParams.get("title") || "";
  const perPage = Number(searchParams.get("perPage")) || 5;

  const newPosts = await client.fetch(BLOG_POSTS_QUERY, {
    lastPublishedAt,
    title,
    perPage,
  });

  return NextResponse.json(newPosts);
}

fetchPosts() 方法則是改用 Next.js 自帶的 fetch() 方法請求:

async function fetchPosts(
  lastPublishedAt = "4000-01-01",
  title = "",
  perPage = 5,
) {
  const newPosts = await fetch(
    `/api/posts?lastPublishedAt=${lastPublishedAt}&title=${title}&perPage=${perPage}`,
  ).then((res) => res.json());
}

這樣 “use client” 所暴露出去的問題就算是修復完了。

( 還好發現得早 )


接著為了不要再發生類似的事情我打算把 Sanity 的搜尋語法抽出來獨立在另一個檔案,剛好也契合之後要導入的在 Draft Mode 即時響應 Sanity Visual Editing 的功能。

這邊打算把 client.fetch() 包裹在一個叫 sanityFetch() 的方法供外部使用,它包含了 Sanity Read Only Token 以及一些快取機制
同時把原先在 ./app/sanity/lib/client.ts 的定義切分開來,把基礎、敏感性低的另外放在 ./app/sanity/lib/base.ts 這個檔案:

.
├── app
│   ├── sanity
│   │   ├── lib
│   │   │   ├── base.ts // ( 非敏感資料 )
│   │   │   ├── client.ts // <- sanityFetch(), 有引入 token, 判斷 draftMode,不應暴露給 client ( 敏感資料 )
│   │   ├── env.ts
│   │   ├── token.ts // <- token 資料 ( 敏感資料 )
// ...
// ./app/sanity/lib/base.ts

import { createDataAttribute, createClient } from "next-sanity";
import imageUrlBuilder from "@sanity/image-url";
import type { SanityImageSource } from "@sanity/image-url/lib/types/types";
import { dataset, projectId, studioUrl } from "../env";

export const sanityClient = createClient({
  projectId,
  dataset,
  useCdn: true,
  apiVersion: "2024-10-05",
  stega: {
    enabled: true,
    studioUrl,
  },
});

export const baseDataAttribute = createDataAttribute({
  baseUrl: studioUrl,
});

export function urlFor(source: SanityImageSource) {
  return imageUrlBuilder(sanityClient).image(source);
}
// ./app/sanity/lib/client.ts

import "server-only";
import { QueryOptions, type QueryParams } from "next-sanity";
import { sanityClient } from "@/app/sanity/lib/base";
import { draftMode } from "next/headers";
import { token } from "@/app/sanity/token";

const clientWithToken = sanityClient.withConfig({
  token: token,
});

export async function sanityFetch<const QueryString extends string>({
  query,
  params = {},
  revalidate = 3600,
  tags = [],
}: {
  query: QueryString;
  params?: QueryParams;
  revalidate?: number | false;
  tags?: string[];
}) {
  const isDraftMode = draftMode().isEnabled;

  if (isDraftMode && !token) {
    throw new Error("Missing environment variable SANITY_API_READ_TOKEN");
  }

  const queryOptions: QueryOptions = {};
  let maybeRevalidate = revalidate;

  if (isDraftMode) {
    queryOptions.token = token;
    // 有三種模式 previewDrafts, published, raw
    queryOptions.perspective = "previewDrafts";
    queryOptions.stega = true;

    maybeRevalidate = 0;
  } else if (tags.length) {
    maybeRevalidate = false;
  }

  return clientWithToken.fetch(query, params, {
    ...queryOptions,
    next: {
      revalidate: maybeRevalidate,
      tags,
    },
  });
}

可以看到在 Draft Mode 內有加入 queryOptions.perspective = "previewDrafts" 的設定,設定為 previewDrafts。

  • previewDrafts 可以讓請求將 已發布正在修改中的內容 都查詢出來
  • published 則是只查詢出已發布的內容
  • row 是預設的查詢方法。

更多解釋與範例可以參考這裡 https://www.sanity.io/docs/perspectives

其他細節就不多,基本上就是在查詢中自動判斷是否為 Draft Mode,並且根據 Draft Mode 改變查詢的條件 catch 機制。

接著就是把之前用到 client.fetch() 的地方都改為 sanityFetch() 方法了。


基本上作到這邊,其實也都完成了。


上一篇
Day 28 - Next.js Draft Mode With Sanity Visual Editing
下一篇
Day 30 - Sanity Visual Editing 即時響應 & 完賽心得
系列文
用 Sanity 跟 Nextjs 重寫個人部落格30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言