Collection Preview (iframe)
Preview = nhúng một
<iframe>trong màn edit record của Studio, trỏ tới URL template do collection cấu hình, nội suy bằng field của record đang edit — để tác giả xem trước trang web thật ngay trong admin. Lấy cảm hứng từ tính năng Live Preview của Directus.
Trạng thái: Đề xuất thiết kế (chưa implement). Không cần migration cho MVP.
1. Ý tưởng
Mỗi collection cấu hình một URL template, ví dụ:
https://staging.mysite.com/blog/{{slug}}
https://mysite.com/posts/{{id}}?preview=1
Khi user mở màn edit một record, Studio nội suy template bằng giá trị field của record (client-side, tái dùng renderer Mustache sẵn có), rồi nhúng URL kết quả vào <iframe> cạnh form. Đổi field → iframe reload (debounce).
Điểm mấu chốt về bảo mật: URL template (do editor cấu hình) tách khỏi allowlist origin (do operator cấu hình qua env). Editor chọn đường dẫn; operator quyết định origin nào được phép nhúng. Kể cả khi tài khoản editor bị chiếm, iframe vẫn không thể trỏ ra origin lạ để lừa đảo hay rò token.
2. Data model
Không thêm cột/migration. Dùng collections.meta (jsonb "UI hints", packages/database/src/schema/cms.ts), thêm namespace preview — cùng pattern với meta.systemFields sẵn có (schema-service.ts). Dữ liệu round-trip sẵn: DB → CompiledCollection.meta → SDK Collection.meta → Studio, không cần đụng route/service/diff.
// collections.meta
{
"preview": {
"enabled": true,
"url": "https://staging.mysite.com/blog/{{slug}}",
"refreshField": "*", // "*" = mọi field đổi thì reload; hoặc tên 1 field
"width": "responsive" // responsive | mobile | desktop
}
}
Zod (đặt trong packages/shared/src/schemas/, export cho CMS + Studio):
export const previewConfigSchema = z.object({
enabled: z.boolean().default(false),
url: z.string().max(2048).default(''), // Mustache template
refreshField: z.string().default('*'),
width: z.enum(['responsive', 'mobile', 'desktop']).default('responsive'),
});
Phương án thay thế (không dùng cho MVP): cột
previewUrl text('preview_url')riêng, mirrordisplayTemplatekhắpCollectionInput/collectionInputSchema/CollectionConfigSchema(.strict()) / compiled shape /buildSchemaDiff. Typed & discoverable hơn nhưng nhiều việc + cần migration.
3. Luồng render (Studio, React)
draft record (item-detail.tsx state)
─► interpolate(meta.preview.url, draft) // Mustache client-side
─► validate origin ∈ PREVIEW_ALLOWED_ORIGINS // defense-in-depth
─► <iframe src={resolvedUrl} sandbox=... />
- UI: thêm tab
previewvào bộ tab củaapps/studio/src/modules/content/item-detail.tsx(hiện'fields' | 'revisions' | 'versions' | 'raw'). Chỉ hiện khimeta.preview.enabled. Nút bật/tắt đặt cạnh nút Share trên toolbar. - Nội suy hoàn toàn client-side (không thêm API call) — tái dùng renderer đồng bộ với
content/mustache-template-editor.tsx+displays/mustache.tsx. - Debounce reload iframe khi
draftđổi (~500ms), theorefreshField. - Hiện URL đã resolve + nút "Open in new tab". Field trống → render
[fieldName]để tác giả biết thiếu dữ liệu. - Preview phải là component first-class, KHÔNG đi qua
sanitize-html(sanitizer strip thẻ iframe).
4. Bảo mật iframe
Hai lớp tin cậy:
| Lớp | Ai kiểm soát | Rủi ro nếu buông |
|---|---|---|
URL template (meta.preview.url) | editor có quyền sửa data-model | trỏ iframe tới origin lạ → phishing trong admin, rò token qua Referer, tabnabbing |
| Origin allowlist (env) | operator/DevOps lúc deploy | — (hàng rào cứng) |
4.1 Allowlist origin qua env
Thêm biến (khớp precedent CORS_ALLOWED_ORIGINS, EXTENSION_BUNDLE_ORIGINS trong apps/cms/src/env.ts):
# nhiều origin cách nhau bằng dấu phẩy
PREVIEW_ALLOWED_ORIGINS=https://staging.mysite.com,https://mysite.com
- Khai báo trong
Bindings(env.ts), set per-env trongapps/cms/wrangler.toml([env.staging.vars]/[env.production.vars]). - Parse bằng
parseAllowedOriginssẵn có (apps/cms/src/config/cors.ts, đã có test). - Production guard: validate trong
apps/cms/src/config/production.ts— cấm*khiLUMIBASE_ENV=production(giốngCORS_ALLOWED_ORIGINS).
Enforcement 2 tầng:
- Backend (nguồn sự thật): khi lưu
meta.preview.url, parse origin của template và chặn nếu không thuộc allowlist →VALIDATION_FAILED. Ngăn cấu hình xấu được lưu ngay từ đầu. - Frontend (defense-in-depth): Studio nhận allowlist (expose qua endpoint config công khai sẵn có, không hardcode) để (a) chỉ render iframe khi origin hợp lệ, (b) khớp với
frame-srcCSP.
4.2 CSP frame-src — bắt buộc
apps/cms/src/middleware/security-headers.ts hiện đặt default-src 'none' và không có frame-src → mọi iframe remote bị chặn. Phải thêm directive frame-src = danh sách allowlist:
'frame-src': parseAllowedOrigins(env.PREVIEW_ALLOWED_ORIGINS),
- Hiện
serializeContentSecurityPolicylà const tĩnh, không đọcc.env. Cần thread env vào middleware (chỉ khi build directive). - KHÔNG đụng
frame-ancestors 'none'vàX-Frame-Options: DENY— chúng bảo vệ Studio khỏi bị nhúng (chống clickjacking), không liên quan tới việc Studio đi nhúng. - Deploy topology: nếu Studio serve standalone trên Cloudflare Pages (không qua CMS worker — xem
apps/studio/src/lib/api-base.ts), CSPframe-srcphải thêm ở phía Pages (_headers). Nếu CMS worker serve Studio HTML (đánh dấuresponseType: 'STUDIO_HTML'quaadmin-path-guard.ts), có thể ápframe-srcriêng chỉ cho surface đó.
4.3 Thuộc tính iframe cứng
<iframe
src={resolvedUrl}
sandbox="allow-scripts allow-same-origin allow-forms"
referrerpolicy="no-referrer"
loading="lazy"
allow="" />
referrerpolicy="no-referrer"→ không rò URL admin (có thể chứa id/token) sang site preview.sandboxtối thiểu.allow-scripts+allow-same-originchỉ an toàn vì preview origin luôn khác origin Studio (đảm bảo bởi allowlist là origin ngoài) — iframe không chọc ngược vào Studio được.allow=""tắt camera/mic/geolocation.- Không bao giờ nhét access token / API key vào URL template — chỉ nội suy field của record.
5. UX cấu hình
Trong màn settings collection (apps/studio/src/modules/data-model/detail.tsx, thêm tab "Preview" cạnh display/archive/raw), copy pattern từ display-tab.tsx:
- Toggle Enable preview.
- Ô nhập URL tái dùng
MustacheTemplateEditor: autocomplete field bằng{{, live preview URL với sample record. - Origin không thuộc allowlist → cảnh báo inline dẫn thẳng cách sửa: "Origin chưa được phép. Liên hệ operator để thêm vào
PREVIEW_ALLOWED_ORIGINS." - Chọn khung Responsive / Mobile / Desktop.
6. Phạm vi triển khai
Giai đoạn 1 (MVP):
packages/shared:previewConfigSchema.apps/cms: envPREVIEW_ALLOWED_ORIGINS(env.ts+wrangler.toml) · validate origin khi lưumeta.preview.url· production guard · expose allowlist cho Studio ·frame-srcCSP trongsecurity-headers.ts.apps/studio/data-model: tab cấu hình Preview (tái dùng Mustache editor).apps/studio/content: tab Preview trongitem-detail.tsx+ component iframe (sandbox + debounce reload).- Docs + Setup Impact Registry (
.kiro/specs/admin-setup-wizard/setup-impact.md) theo Definition of Done.
Giai đoạn 2 (tùy chọn):
- Draft preview token: preview secret ngắn hạn, read-only, do CMS phát cho phiên preview (KHÔNG phải session token admin) để site FE render bản draft — giống preview mode của Directus/Next.js.
- postMessage: đồng bộ scroll / hot-reload không cần reload cả iframe.
7. So với Directus
| Directus | LumiBase (đề xuất) | |
|---|---|---|
| Nơi lưu | collections.preview_url (meta) | collections.meta.preview.url |
| Template | {{ field }} | Mustache {{ field }} (tái dùng display template) |
| Chặn origin | (không có allowlist env) | PREVIEW_ALLOWED_ORIGINS + frame-src CSP + validate lúc lưu |
| Draft | preview mode + token | Giai đoạn 2 |