這個網站是怎麼做的:地端 Puck 編輯器 × Azure 雲端靜態網站
這是網站的第一篇文章,就先來聊聊網站本身。當初我想要三件事:
- 視覺化編輯器:調整首頁版面時,不用手改 HTML。
- 免費、快速的公開主機:要有 HTTPS,而且不用自己維護伺服器。
- 很難被竄改:除了我以外,沒有人能改變訪客看到的內容。
WordPress 這類常見的 CMS 能滿足前兩項,代價卻是第三項。編輯器、資料庫和公開網站全都放在同一台對外開放的伺服器上。我的做法是把它們拆成兩半:編輯器只在我自己的電腦上執行,雲端永遠只放靜態檔案。
架構總覽
整個系統分成兩半,中間只靠一個 git push 連接:
| 我的電腦(私有) | 雲端(公開) | |
|---|---|---|
| 用途 | 編輯與建置 | 提供網頁 |
| 軟體 | Puck 編輯器、匯出程式、Node.js | GitHub、GitHub Actions、Azure Static Web Apps |
| 誰能存取 | 只有我(localhost) |
所有人,唯讀 |
| 能不能修改內容? | 可以 | 只能透過 GitHub 部署 |
程式碼放在兩個私人的 GitHub repository:
me-site-builder:編輯器、頁面資料、Markdown 文章和匯出程式。me-site:只放產生出來的 HTML,加上一個部署用的 workflow。
地端:Puck 編輯器 + 匯出程式
Puck 是一套開源、可拖拉的 React 視覺化編輯器。你在設定檔裡描述網站的組成區塊(component),Puck 就會產生一個編輯器,讓你把區塊拖到頁面上、填寫欄位。每個頁面都存成單純的 JSON。
我為這個網站做了這些區塊:自我介紹(Hero)、關於我、技能、經歷、作品、聯絡連結、文章列表,以及幾個基本元件。網站是中英雙語的,所以每個文字欄位都同時存兩種語言:編輯器裡同一個欄位會並排顯示「繁體中文」和「English」兩個輸入框,上方還有切換鈕,決定畫面預覽哪個語言。每個區塊都只是一般的 React 元件,用一個小小的 t() 函式挑出對應語言的文字:
Hero: {
fields: {
name: lt("名字"), // { "zh-TW": "…", en: "…" }
role: lt("職稱"),
tagline: lt("一句話介紹", "textarea"),
},
render: ({ name, role, tagline, puck }) => {
const { locale } = puck.metadata;
return (
<section className="s-hero">
<h1>{t(name, locale)}</h1>
<p>{t(role, locale)}</p>
<p>{t(tagline, locale)}</p>
</section>
);
},
},
編輯器跑在 Vite 開發伺服器上,而且只綁定 127.0.0.1,同一個網路上的其他電腦都連不到。按下 Publish 時,一個小型的本機 API 會做兩件事:
- 把頁面 JSON 存到
data/pages/<page>.json。 - 執行匯出程式。
匯出程式是整個設計的核心。它重用同一份 Puck 設定,但改在伺服器端用 React 渲染:
import { renderToStaticMarkup } from "react-dom/server";
import { Render } from "@puckeditor/core/rsc";
const html = renderToStaticMarkup(
<Render config={config} data={pageJson} metadata={{ locale, base, posts }} />,
);
每個頁面會依語言各渲染一次:繁體中文放在 /tw/,英文放在 /en/;網址 / 會依瀏覽器語言自動轉到對應版本(不是中文就用英文)。每個版本都有正確的 lang 屬性、hreflang 連結,部落格頁面上也有語言切換。匯出程式把結果包進 HTML 外殼(標題、描述、Open Graph 標籤),寫到 me-site/site/。它還會:
- 把像這篇一樣的 Markdown 文章轉成
/blog/<slug>/頁面,程式碼高亮在建置時就完成(同一個 slug 的post.zh-tw.md和post.en.md會被視為彼此的翻譯); - 產生文章列表、404 頁面,以及 RSS 和 sitemap;
- 產生
staticwebapp.config.json,告訴 Azure 要送出哪些安全標頭。
輸出的結果是純 HTML 和 CSS。唯一的 JavaScript 是首頁的 3D 互動場景:用 three.js 畫出一個小小的太陽系:我的頭像化成一團粒子,是正中央的恆星;我使用的技術(React、Python、Azure、PostgreSQL、Claude…)則是各自軌道上的行星,點擊行星就會開啟該標籤的文章。可以拖曳調整整個星系的角度,也能用游標掃過恆星把粒子撥散。行星的種類、順序、顏色和圖示,都在編輯器裡設定。匯出程式把它打包成一支自己架設的檔案 assets/hero3d.js,而且只有首頁會載入。其他頁面完全不含 JavaScript;首頁上的名字和 GitHub 連結也都是純 HTML,就算瀏覽器不執行腳本、不支援 WebGL 也能正常顯示。我在本機預覽到的,和訪客拿到的內容一模一樣。
雲端:GitHub + Azure Static Web Apps
Azure Static Web Apps 會從微軟的全球節點提供靜態檔案。免費方案就包含 HTTPS 憑證和自訂網域,非常適合個人網站。
部署只是 me-site repo 裡一個簡短的 GitHub Actions workflow。因為沒有東西需要編譯,它只是把資料夾上傳:
- uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
action: upload
app_location: site
skip_app_build: true
部署 token 以加密的 GitHub secret 保存,不會出現在程式碼或執行紀錄裡。
一個下午就能做出自己的網站
以下是完整流程,從一台空的筆電到一個公開的網站。
1. 建立兩個 repo(設成私人也可以),clone 到同一層資料夾,啟動編輯器
mkdir ~/code && cd ~/code
git clone https://github.com/<你的帳號>/me-site.git
git clone https://github.com/<你的帳號>/me-site-builder.git
cd me-site-builder
npm install
npm run dev # 打開 http://localhost:5173
2. 設計頁面。 從左邊拖拉區塊,點選區塊後在右邊同時填寫中英文內容。用上方的「中 / EN」切換預覽語言,用頁面選單新增像 /about/ 這樣的頁面。按 Publish 存檔並匯出,按 預覽 查看匯出後的網站。
3. 用 Markdown 寫文章
npm run new-post -- "我的第一篇文章" --slug my-first-article
# 建立 content/posts/YYYY-MM-DD-my-first-article.zh-tw.md(草稿)
npm run new-post -- "My first article" --lang en --slug my-first-article
# 英文翻譯,使用相同的 slug
準備好發佈時,把 front matter 裡的 draft: true 刪掉。
4. 建立 Azure Static Web App(只需一次)
- 在 Azure Portal 建立 Static Web App,方案選 Free,部署來源選 Other。
- 複製它的部署 token,加到
me-siteGitHub repo 的 secret,名稱為AZURE_STATIC_WEB_APPS_API_TOKEN。
5. 發佈
npm run publish:site
這個指令會匯出網站、在 me-site repo commit site/ 並 push。GitHub Actions 接手部署,大約一分鐘後就會出現在 https://<name>.azurestaticapps.net(或你的自訂網域)。
為什麼這個架構很難被入侵或竄改
大多數網站被竄改,根本原因都一樣:公開的網站本身就有能力修改自己的內容。 登入頁面、管理後台、有檔案上傳漏洞的外掛、可以被 SQL injection 的資料庫,每一個都讓攻擊者能從網路上改寫內容。這個設計直接把這種能力從公開端移除。
1. 公開端沒有任何東西能寫入
Azure 提供的網站只是一個裝滿靜態檔案的資料夾。它:
- 沒有管理後台或登入頁面,所以沒有密碼可以暴力破解或釣魚。
- 沒有資料庫,SQL injection 無處可注入。
- 沒有伺服器端程式(沒有 PHP、沒有 Node 執行環境、沒有外掛),也就沒有遠端程式碼執行可以利用。
訪客只能讀取已經存在的檔案。要改變內容,唯一的方法是部署新版本,而部署需要的憑證,公開端完全接觸不到。
2. 編輯器不在網路上
編輯器和它的存檔、匯出 API,只有在我電腦上執行 npm run dev 時才存在。它們只監聽 127.0.0.1,連同一個 Wi-Fi 下的其他裝置都連不到。在一般的 CMS 裡,編輯器是最有價值的攻擊目標;在這裡,它根本連不到。
3. 唯一一條、需要驗證的寫入路徑
內容進到正式環境只有一條路:git push 到 GitHub → GitHub Actions → Azure。攻擊者想發佈惡意內容,必須取得以下其中之一:
- 我的 GitHub 帳號:有雙因素驗證或通行金鑰(passkey)保護;
- Azure 部署 token:加密存放在 GitHub secrets,隨時可以在 Azure Portal 重設;
- 我的 Azure 帳號:同樣有 MFA 保護。
守住三個清楚明確的帳號,遠比守住一整個網頁應用程式容易得多。
4. 每次修改都有版本紀錄,可以立即回復
發佈出去的 HTML 都 commit 在 git 裡,所以每次部署都有可審閱的差異(diff)和作者。萬一出現意料之外的內容,我能清楚看到改了什麼。要回復時,執行 git revert 再 push,Azure 就會重新部署上一個版本。也可以在 main 設定分支保護規則,要求必須經過 pull request,這樣就算筆電的登入工作階段被偷,也無法悄悄推上正式環境。
5. 幾乎不含 JavaScript,搭配嚴格的內容安全政策(CSP)
網站上唯一的腳本是我自己打包、放在自己網域上的 3D 程式,所以 Azure 可以在每個回應都附上非常嚴格的政策:
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none';
base-uri 'none'; form-action 'none'; frame-ancestors 'none'; ...
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
script-src 'self' 代表瀏覽器只執行來自我自己網站的腳本檔案;內嵌腳本、eval 和其他網域的腳本一律拒絕,就算有腳本被注入頁面也一樣。這就擋掉了常見的跨站腳本攻擊(XSS)。3D 場景只是在 canvas 上繪圖,沒有表單、沒有 cookie、不呼叫任何 API,也沒有任何東西可以被它改動。frame-ancestors 'none' 防止其他網站把我的網站嵌入、進行點擊劫持(clickjacking),HSTS 則強制使用 HTTPS。
6. 建置時的套件只留在建置時
React、Puck、Markdown 解析器和 node_modules 裡的其他套件,只會在我電腦上匯出時執行,不會被部署。唯一的例外是 three.js:它被打包進 assets/hero3d.js,在訪客的瀏覽器裡執行,碰不到任何能修改網站的東西。就算某個建置時用的套件有漏洞,也無法透過線上網站被利用,因為線上網站裡根本沒有它的程式碼。部署出去的只有它產生的結果,而這些結果我都能在 git diff 裡檢查。
這個設計防不了什麼
沒有任何架構是「無法被駭」的。以下是剩下的風險,以及我的因應方式:
- 我的電腦。 如果電腦中了惡意程式,內容可能在 push 之前就被竄改。我會保持系統更新、啟用磁碟加密,並在發佈前檢查 diff。
- 我的帳號。 現在 GitHub 和 Azure 就是網站的鑰匙。兩邊我都使用通行金鑰或 MFA,也不授予任何不必要的權限。
- 部署 token。 萬一外洩,在 Azure 重設 token、更新 GitHub secret 就好,幾分鐘就能完成。
- DNS。 使用自訂網域時,誰掌控 DNS,誰就能決定訪客被導到哪裡。我會用 MFA 和網域鎖定(registrar lock)保護網域註冊商帳號。
結語
這套架構把網站拆成私有、功能完整的編輯器和公開、唯讀的網站。我在本機享有拖拉編輯和 Markdown 寫作,訪客拿到的則是快速、只走 HTTPS 的靜態頁面。公開端幾乎沒有攻擊面,因為上面沒有任何東西能修改內容。
我的 repo 是私人的,所以這篇是架構說明、不提供原始碼下載;用到的 Puck、Vite、Azure Static Web Apps 都是公開且可以免費使用的。感謝閱讀!