在要寫前一篇的後續:「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。
更多解釋與範例可以參考這裡 https://www.sanity.io/docs/perspectives
其他細節就不多,基本上就是在查詢中自動判斷是否為 Draft Mode,並且根據 Draft Mode 改變查詢的條件 catch 機制。
接著就是把之前用到 client.fetch()
的地方都改為 sanityFetch() 方法了。
基本上作到這邊,其實也都完成了。