バックエンド連携
データベース設計・保存リクエストの受け方(save-request / save-result)・セキュリティ対策など、サーバー側で実装する内容です。
データベース構成
ZeroCode.jsのデータをRDBで管理する場合の推奨データベース設計です。
テーブル構成
以下の3つのテーブルで構成されます:
- zcode_common_parts: 共通パーツ管理(店舗に依存しない)
- zcode_common_images: 共通画像管理(店舗に依存しない)
- zcode_individual: 店舗ごとのページ・個別パーツ・個別画像
zcode_common_parts(共通パーツ)
すべての店舗で共有される共通パーツを管理します。
CREATE TABLE zcode_common_parts (
id VARCHAR(50) PRIMARY KEY,
type VARCHAR(50) NOT NULL,
description TEXT,
parts JSON NOT NULL,
version INT NOT NULL DEFAULT 1,
is_published BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
| カラム名 | 型 | NULL | 説明 |
|---|---|---|---|
id |
VARCHAR(50) | NO | パーツタイプID(PK) |
type |
VARCHAR(50) | NO | パーツタイプ(例: hero, features) |
description |
TEXT | YES | タイプの説明 |
parts |
JSON | NO | パーツ配列(PartData[]形式) |
version |
INT | NO | バージョン番号(楽観的ロック用) |
is_published |
BOOLEAN | NO | 公開フラグ |
created_at |
TIMESTAMP | NO | 作成日時 |
updated_at |
TIMESTAMP | NO | 更新日時 |
zcode_common_images(共通画像)
すべての店舗で共有される共通画像を管理します。
CREATE TABLE zcode_common_images (
id VARCHAR(50) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
url TEXT NOT NULL,
mime_type VARCHAR(50) NOT NULL,
needs_upload BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
| カラム名 | 型 | NULL | 説明 |
|---|---|---|---|
id |
VARCHAR(50) | NO | 画像ID(PK) |
name |
VARCHAR(255) | NO | 画像名 |
url |
TEXT | NO | 画像URL(base64の場合はdata:image/...形式) |
mime_type |
VARCHAR(50) | NO | MIMEタイプ(例: image/jpeg, image/png) |
needs_upload |
BOOLEAN | NO |
アップロード要否(trueの場合、バックエンドでアップロード処理が必要)
|
created_at |
TIMESTAMP | NO | 作成日時 |
updated_at |
TIMESTAMP | NO | 更新日時 |
zcode_individual(店舗ごとの個別データ)
店舗ごとのページ、個別パーツ、個別画像を管理します。
CREATE TABLE zcode_individual (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
data_type ENUM('page', 'parts', 'images') NOT NULL,
content JSON NOT NULL,
version INT NOT NULL DEFAULT 1,
status ENUM('draft', 'published', 'archived') NOT NULL DEFAULT 'draft',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_type_status (user_id, data_type, status)
);
| カラム名 | 型 | NULL | 説明 |
|---|---|---|---|
id |
BIGINT | NO | レコードID(PK, AUTO_INCREMENT) |
user_id |
BIGINT | NO | 店舗ID(外部キー) |
data_type |
ENUM | NO | データ種別(page, parts, images) |
content |
JSON | NO |
データ内容(ComponentData[], TypeData[],
ImageData[]形式)
|
version |
INT | NO | バージョン番号(楽観的ロック用) |
status |
ENUM | NO |
ステータス(draft: 下書き, published: 公開,
archived: アーカイブ)
|
created_at |
TIMESTAMP | NO | 作成日時 |
updated_at |
TIMESTAMP | NO | 更新日時 |
データ例
zcode_individual のデータ例
-- 店舗1(user_id=1)のページデータ(下書き)
INSERT INTO zcode_individual (user_id, data_type, content, status) VALUES (
1,
'page',
'[{"id": "hero-1", "part_id": "zcode-part-1", "title": "店舗1のタイトル", ...}]',
'draft'
);
-- 店舗1(user_id=1)のページデータ(公開版)
INSERT INTO zcode_individual (user_id, data_type, content, status) VALUES (
1,
'page',
'[{"id": "hero-1", "part_id": "zcode-part-1", "title": "店舗1のタイトル", ...}]',
'published'
);
-- 店舗1(user_id=1)の個別パーツデータ
INSERT INTO zcode_individual (user_id, data_type, content, status) VALUES (
1,
'parts',
'[{"id": "zcode-part-12", "type": "cta", "description": "店舗専用のCTA", ...}]',
'published'
);
-- 店舗1(user_id=1)の個別画像データ
INSERT INTO zcode_individual (user_id, data_type, content, status) VALUES (
1,
'images',
'[{"id": "img-ind-1", "name": "店舗専用画像", "url": "/images/store1-hero.jpg", ...}]',
'published'
);
クエリ例
店舗の公開ページデータを取得
SELECT content
FROM zcode_individual
WHERE user_id = 1
AND data_type = 'page'
AND status = 'published'
LIMIT 1;
店舗の下書きページデータを取得
SELECT content
FROM zcode_individual
WHERE user_id = 1
AND data_type = 'page'
AND status = 'draft'
LIMIT 1;
共通パーツを取得
SELECT parts
FROM zcode_common_parts
WHERE is_published = TRUE;
設計の特徴
- 共通データの一元管理: 共通パーツと共通画像は1箇所で管理され、すべての店舗で共有されます
- 個別データの統合管理: 店舗ごとのページ、個別パーツ、個別画像を1つのテーブルで管理します
- ステータス管理: 下書き・公開・アーカイブを1テーブルで管理できます(1店舗あたり最大9レコード: 3種別 × 3ステータス)
-
楽観的ロック:
versionカラムを使用して同時更新の競合を防ぎます -
スケーラビリティ:
200店舗規模でも問題なく動作します(インデックス
idx_user_type_statusが有効)
注意事項
-
contentカラムはJSON型またはLONGTEXT型を使用してください(データ量が多い場合) -
MySQLの場合、
JSON型は最大1GBですが、実用的には数MB程度を目安にしてください - PostgreSQLの場合、
JSONB型を使用すると検索パフォーマンスが向上します - 更新時は
versionカラムをチェックして楽観的ロックを実装してください
保存リクエスト
保存ボタンをクリックすると、save-requestイベントが発火します。
イベントの受け取り方
event.detail に data は含まれません。cms.getData()
で取得してください。
const cms = document.getElementById('cms');
cms.addEventListener('save-request', (event) => {
const { requestId, source, targets, timestamp } = event.detail;
const data = cms.getData();
for (const target of targets) {
fetch('/api/zero-code/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target, source, data, requestId, timestamp })
});
}
});
保存ターゲットの仕様
save-requestイベントのtargets配列には、現在のタブやモードに応じて複数のターゲットが含まれる場合があります。
| タブ/モード | 含まれるターゲット | 説明 |
|---|---|---|
| ページ管理(編集モード) |
['page', 'images-special']
|
ページデータと専用画像プールを保存対象として通知(ホストが永続化方針を決定) |
| パーツ管理 |
['parts-common', 'parts-common-css']、['parts-individual', 'parts-individual-css']、または['parts-special', 'parts-special-css']
|
パーツデータとページCSSを保存(パーツ編集時にページCSSも編集可能なため) |
| 画像管理 |
['images-common']、['images-individual']、または['images-special']
|
画像データのみを保存 |
| データビューアー | 選択中のタブとカテゴリに応じて決定 | 現在表示中のデータに対応するターゲット |
画像のアップロード
targets配列にimages-common、images-individual、またはimages-specialが含まれる場合、needsUpload: trueの画像をアップロード処理してください。data はハンドラ先頭で
cms.getData() したもの。
for (const target of targets) {
if (target.startsWith('images-')) {
const images = target === 'images-common'
? data.images?.common || []
: target === 'images-individual'
? data.images?.individual || []
: data.images?.special || [];
const imagesToUpload = images.filter(img => img.needsUpload === true);
for (const image of imagesToUpload) {
// base64データをデコード
const base64Data = image.url.replace(/^data:image\/\w+;base64,/, '');
const buffer = Buffer.from(base64Data, 'base64');
// S3などにアップロード
// アップロード後、image.urlを更新し、needsUploadをfalseに設定
}
}
}
バリデーションエラーの返却
バックエンドでバリデーションを行い、エラーがある場合はsave-resultイベントでエラーを返してください。フロントエンドでは、requiredとmaxのみをチェックします。複雑なバリデーション(メール形式チェック、重複チェックなど)はバックエンドで実施してください。
イベント形式:
// 成功時
cms.dispatchEvent(
new CustomEvent('save-result', {
detail: {
requestId: 'req-1234567890-abc', // save-request の requestId
target: 'page', // この回の保存対象(targets の要素のいずれか)
ok: true,
errors: []
},
bubbles: true,
composed: true
})
);
// エラー時
cms.dispatchEvent(
new CustomEvent('save-result', {
detail: {
requestId: 'req-1234567890-abc', // save-request の requestId
target: 'page', // この回の保存対象(targets の要素のいずれか)
ok: false,
errors: [
{
path: 'page.0', // コンポーネントのパス(オプション、指定しない場合はすべてのコンポーネントに適用)
field: 'email', // フィールド名
message: 'メールアドレスの形式が正しくありません', // エラーメッセージ
code: 'FORMAT' // エラーコード(オプション)
}
]
},
bubbles: true,
composed: true
})
);
実装例(Node.js/Express):
cms.addEventListener('save-request', async (event) => {
const { requestId, source, targets, timestamp } = event.detail;
const data = cms.getData();
for (const target of targets) {
try {
// バリデーション
const errors = [];
if (target === 'page') {
// ページデータのバリデーション
const pageData = data.page || [];
for (let i = 0; i < pageData.length; i++) {
const component = pageData[i];
// メール形式チェック(例)
if (component.email && !component.email.includes('@')) {
errors.push({
path: `page.${i}`,
field: 'email',
message: 'メールアドレスの形式が正しくありません',
code: 'FORMAT'
});
}
// 重複チェック(例)
if (component.title && await isTitleDuplicate(component.title)) {
errors.push({
path: `page.${i}`,
field: 'title',
message: 'このタイトルは既に使用されています',
code: 'DUPLICATE'
});
}
}
} else if (target === 'parts-common-css' || target === 'parts-individual-css' || target === 'parts-special-css') {
// CSSのバリデーション(例)
const css = target === 'parts-common-css' ? data.css?.common :
target === 'parts-individual-css' ? data.css?.individual :
data.css?.special || '';
if (css.length > 10000) {
errors.push({
field: target,
message: 'CSSのサイズが大きすぎます',
code: 'SIZE_LIMIT'
});
}
} else if (target.startsWith('images-')) {
// 画像データのアップロード処理
const images = target === 'images-common'
? data.images?.common || []
: target === 'images-individual'
? data.images?.individual || []
: data.images?.special || [];
for (const image of images) {
if (image.needsUpload) {
// 画像をアップロード
const uploadedUrl = await uploadImage(image);
image.url = uploadedUrl;
image.needsUpload = false;
}
}
}
if (errors.length > 0) {
// エラーがある場合
cms.dispatchEvent(
new CustomEvent('save-result', {
detail: {
requestId,
target,
ok: false,
errors
},
bubbles: true,
composed: true
})
);
continue; // 次のターゲットへ
}
// データベースに保存
await saveToDatabase(target, data);
// 成功時
cms.dispatchEvent(
new CustomEvent('save-result', {
detail: {
requestId,
target,
ok: true,
errors: []
},
bubbles: true,
composed: true
})
);
} catch (error) {
// エラー時
cms.dispatchEvent(
new CustomEvent('save-result', {
detail: {
requestId,
target,
ok: false,
errors: [
{
field: 'general',
message: '保存に失敗しました',
code: 'SAVE_FAILED'
}
]
},
bubbles: true,
composed: true
})
);
}
}
}
});
エラーの表示:
-
編集パネルが開いている場合:
エラーは該当フィールドの下に表示されます。
pathが指定されている場合、そのパスのコンポーネントのフィールドにのみ表示されます。 - 編集パネルが閉じている場合: エラーは画面上部にエラーバナーとして表示されます。編集パネルを開くと、該当フィールドに詳細エラーが表示されます。
注意:
requestIdはsave-requestイベントのrequestIdと同じ値を使用してください。これにより、複数の保存リクエストが同時に発火した場合でも、正しいレスポンスとリクエストを関連付けることができます。
セキュリティ
重要: ZeroCode.jsはフロントエンドライブラリのため、クライアント側での完全なセキュリティ保証はできません。サーバー側での検証を必ず実装してください。
セキュリティモデル(信頼境界)
ZeroCode.js は HTML を生成できる入力は信頼できる人に限ることを前提としています。
-
信頼する入力(そのまま描画):
パーツテンプレート(
part.body)・パーツ用CSS・backendData・ホストからの API 呼び出し。書き込めるのはエンジニア(Editor)・制作会社(Studio)・ホストのみにしてください -
信頼しない入力(ライブラリが無害化): CMS
で入力するフィールド値。リッチテキストは許可リストで無害化し、URL 属性(
hrefxlink:hrefsrcなど)は展開後の最終値からjavascript:等を除去します。公開表示・編集画面・SSR のすべてに適用されます -
Studio
を外部の制作会社に開放するなど、パーツテンプレートを信頼できない主体に編集させる場合は、
studio.sanitizePartTemplate: trueに加えて、サーバー側でもsanitizePartTemplateを再実行してください
CSP(Content Security Policy)
ZeroCode.js は unsafe-eval を必要としません。CSP
を設定する場合は、次を許可してください。
-
style-src 'unsafe-inline'(UI とパーツ用 CSS を<style>要素で適用するため) -
img-srcに画像の配信元とdata:(アップロード前の画像の表示) -
Editor / Studio のパーツ管理でコードを編集する場合のみ:
script-srcとstyle-srcにhttps://cdn.jsdelivr.net、font-src data:、worker-src blob:(Monaco エディタを CDN から読み込むため)
必須実装事項
1. サーバー側でのデータ検証(必須)
- すべてのデータをサーバー側で検証してください
-
パーツテンプレート(
part.body)が信頼できるソースからのみ来ることを確認してください -
save-requestイベントのsourceフィールドを確認し、CMSからのパーツデータ保存を拒否してください
2. 認証・認可
- パーツデータの変更は認証されたユーザーのみ許可してください
- ロールベースアクセス制御を実装してください
- CMS(
source: 'cms')からのパーツデータ保存を拒否してください
3. 属性値のセキュリティ
-
URL属性(
href,src,action)はライブラリ側でも検証されますが、サーバー側での追加検証を推奨します -
style属性にユーザー入力を直接設定する場合は、サーバー側での検証を推奨します - 基本的なエスケープ処理が適用されますが、サーバー側での追加検証を推奨します
4. パーツテンプレートの管理
- パーツテンプレートは信頼できるソースからのみ受け入れてください
- サーバー側でテンプレートの妥当性を検証してください
実装例
※ クライアントは save-request ハンドラで cms.getData() し、targets
をループして各 target と data を
req.body に含めて送る。
// Node.js/Express の例(サーバー側)
app.post('/api/zero-code/save', authenticate, (req, res) => {
const { target, source, data } = req.body;
if (source === 'cms' && target.startsWith('parts-')) {
return res.status(403).json({
error: 'CMSからのパーツ保存は拒否されました'
});
}
// データ構造の検証
if (!validateDataStructure(data)) {
return res.status(400).json({
error: '無効なデータです'
});
}
// パーツテンプレートの検証
if (target.startsWith('parts-')) {
if (!validatePartTemplate(data)) {
return res.status(400).json({
error: '無効なテンプレートです'
});
}
}
// 画像アップロード処理
if (target.startsWith('images-')) {
const imagesToUpload = data.filter(img => img.needsUpload === true);
for (const image of imagesToUpload) {
// アップロード処理
}
}
// 保存処理
// ...
res.json({ success: true });
});
画像アップロードのセキュリティ
needsUpload: trueの画像のみアップロード処理を行ってください- MIMEタイプ(
mimeType)の検証を実装してください - ファイルサイズの制限を設定してください
- ファイル名のサニタイズを実装してください