@batterai/strapi-plugin-canvas 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/LICENSE +16 -0
- package/README.md +131 -0
- package/dist/admin/appearance.d.ts +12 -0
- package/dist/admin/appearance.d.ts.map +1 -0
- package/dist/admin/appearance.js +44 -0
- package/dist/admin/appearance.js.map +1 -0
- package/dist/admin/block-packages.d.ts +4 -0
- package/dist/admin/block-packages.d.ts.map +1 -0
- package/dist/admin/block-packages.js +14 -0
- package/dist/admin/block-packages.js.map +1 -0
- package/dist/admin/client/content-model.d.ts +3 -0
- package/dist/admin/client/content-model.d.ts.map +1 -0
- package/dist/admin/client/content-model.js +76 -0
- package/dist/admin/client/content-model.js.map +1 -0
- package/dist/admin/client/host-services.d.ts +4 -0
- package/dist/admin/client/host-services.d.ts.map +1 -0
- package/dist/admin/client/host-services.js +141 -0
- package/dist/admin/client/host-services.js.map +1 -0
- package/dist/admin/index.d.ts +37 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +34 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/pages/App.d.ts +4 -0
- package/dist/admin/pages/App.d.ts.map +1 -0
- package/dist/admin/pages/App.js +50 -0
- package/dist/admin/pages/App.js.map +1 -0
- package/dist/admin/translations.d.ts +4 -0
- package/dist/admin/translations.d.ts.map +1 -0
- package/dist/admin/translations.js +16 -0
- package/dist/admin/translations.js.map +1 -0
- package/dist/constants.d.ts +14 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +18 -0
- package/dist/constants.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/server/bootstrap.d.ts +4 -0
- package/dist/server/bootstrap.d.ts.map +1 -0
- package/dist/server/bootstrap.js +37 -0
- package/dist/server/bootstrap.js.map +1 -0
- package/dist/server/config/index.d.ts +24 -0
- package/dist/server/config/index.d.ts.map +1 -0
- package/dist/server/config/index.js +33 -0
- package/dist/server/config/index.js.map +1 -0
- package/dist/server/content-types/canvas-page/schema.d.ts +45 -0
- package/dist/server/content-types/canvas-page/schema.d.ts.map +1 -0
- package/dist/server/content-types/canvas-page/schema.js +21 -0
- package/dist/server/content-types/canvas-page/schema.js.map +1 -0
- package/dist/server/content-types/canvas-site/schema.d.ts +41 -0
- package/dist/server/content-types/canvas-site/schema.d.ts.map +1 -0
- package/dist/server/content-types/canvas-site/schema.js +20 -0
- package/dist/server/content-types/canvas-site/schema.js.map +1 -0
- package/dist/server/content-types/index.d.ts +90 -0
- package/dist/server/content-types/index.d.ts.map +1 -0
- package/dist/server/content-types/index.js +7 -0
- package/dist/server/content-types/index.js.map +1 -0
- package/dist/server/controllers/canvas.d.ts +48 -0
- package/dist/server/controllers/canvas.d.ts.map +1 -0
- package/dist/server/controllers/canvas.js +409 -0
- package/dist/server/controllers/canvas.js.map +1 -0
- package/dist/server/controllers/index.d.ts +448 -0
- package/dist/server/controllers/index.d.ts.map +1 -0
- package/dist/server/controllers/index.js +5 -0
- package/dist/server/controllers/index.js.map +1 -0
- package/dist/server/index.d.ts +645 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +21 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/license.d.ts +13 -0
- package/dist/server/license.d.ts.map +1 -0
- package/dist/server/license.js +24 -0
- package/dist/server/license.js.map +1 -0
- package/dist/server/policies/is-admin-authenticated.d.ts +2 -0
- package/dist/server/policies/is-admin-authenticated.d.ts.map +1 -0
- package/dist/server/policies/is-admin-authenticated.js +4 -0
- package/dist/server/policies/is-admin-authenticated.js.map +1 -0
- package/dist/server/register.d.ts +2 -0
- package/dist/server/register.d.ts.map +1 -0
- package/dist/server/register.js +4 -0
- package/dist/server/register.js.map +1 -0
- package/dist/server/routes/admin.d.ts +18 -0
- package/dist/server/routes/admin.d.ts.map +1 -0
- package/dist/server/routes/admin.js +38 -0
- package/dist/server/routes/admin.js.map +1 -0
- package/dist/server/routes/index.d.ts +31 -0
- package/dist/server/routes/index.d.ts.map +1 -0
- package/dist/server/routes/index.js +7 -0
- package/dist/server/routes/index.js.map +1 -0
- package/dist/server/routes/public.d.ts +13 -0
- package/dist/server/routes/public.d.ts.map +1 -0
- package/dist/server/routes/public.js +7 -0
- package/dist/server/routes/public.js.map +1 -0
- package/dist/server/services/collaboration-service.d.ts +24 -0
- package/dist/server/services/collaboration-service.d.ts.map +1 -0
- package/dist/server/services/collaboration-service.js +164 -0
- package/dist/server/services/collaboration-service.js.map +1 -0
- package/dist/server/services/document-command-service.d.ts +34 -0
- package/dist/server/services/document-command-service.d.ts.map +1 -0
- package/dist/server/services/document-command-service.js +418 -0
- package/dist/server/services/document-command-service.js.map +1 -0
- package/dist/server/services/document-service.d.ts +14 -0
- package/dist/server/services/document-service.d.ts.map +1 -0
- package/dist/server/services/document-service.js +144 -0
- package/dist/server/services/document-service.js.map +1 -0
- package/dist/server/services/index.d.ts +68 -0
- package/dist/server/services/index.d.ts.map +1 -0
- package/dist/server/services/index.js +28 -0
- package/dist/server/services/index.js.map +1 -0
- package/dist/server/services/page-doc-store.d.ts +59 -0
- package/dist/server/services/page-doc-store.d.ts.map +1 -0
- package/dist/server/services/page-doc-store.js +285 -0
- package/dist/server/services/page-doc-store.js.map +1 -0
- package/dist/server/services/site-store.d.ts +43 -0
- package/dist/server/services/site-store.d.ts.map +1 -0
- package/dist/server/services/site-store.js +212 -0
- package/dist/server/services/site-store.js.map +1 -0
- package/dist/server-cjs/index.js +17381 -0
- package/dist/server-cjs/package.json +3 -0
- package/package.json +77 -0
- package/src/admin/appearance.ts +56 -0
- package/src/admin/block-packages.ts +16 -0
- package/src/admin/client/content-model.ts +88 -0
- package/src/admin/client/host-services.ts +172 -0
- package/src/admin/index.tsx +56 -0
- package/src/admin/pages/App.tsx +56 -0
- package/src/admin/strapi-admin.d.ts +1 -0
- package/src/admin/translations.ts +23 -0
- package/src/constants.ts +21 -0
- package/src/index.ts +11 -0
- package/src/server/bootstrap.ts +45 -0
- package/src/server/config/index.ts +46 -0
- package/src/server/content-types/canvas-page/schema.ts +20 -0
- package/src/server/content-types/canvas-site/schema.ts +19 -0
- package/src/server/content-types/index.ts +7 -0
- package/src/server/controllers/canvas.ts +468 -0
- package/src/server/controllers/index.ts +5 -0
- package/src/server/index.ts +21 -0
- package/src/server/license.ts +42 -0
- package/src/server/policies/is-admin-authenticated.ts +3 -0
- package/src/server/register.ts +3 -0
- package/src/server/routes/admin.ts +40 -0
- package/src/server/routes/index.ts +7 -0
- package/src/server/routes/public.ts +6 -0
- package/src/server/services/collaboration-service.ts +180 -0
- package/src/server/services/document-command-service.ts +495 -0
- package/src/server/services/document-service.ts +162 -0
- package/src/server/services/index.ts +41 -0
- package/src/server/services/page-doc-store.ts +348 -0
- package/src/server/services/site-store.ts +243 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
kind: "collectionType",
|
|
3
|
+
collectionName: "canvas_plugin_pages",
|
|
4
|
+
info: {
|
|
5
|
+
singularName: "canvas-page",
|
|
6
|
+
pluralName: "canvas-pages",
|
|
7
|
+
displayName: "Canvas page",
|
|
8
|
+
},
|
|
9
|
+
options: { draftAndPublish: false },
|
|
10
|
+
pluginOptions: {
|
|
11
|
+
"content-manager": { visible: false },
|
|
12
|
+
"content-type-builder": { visible: false },
|
|
13
|
+
},
|
|
14
|
+
attributes: {
|
|
15
|
+
pageKey: { type: "string", required: true, unique: true, configurable: false },
|
|
16
|
+
docVersion: { type: "integer", required: true, default: 0, configurable: false },
|
|
17
|
+
latestVersion: { type: "string", configurable: false },
|
|
18
|
+
document: { type: "json", required: true, configurable: false },
|
|
19
|
+
},
|
|
20
|
+
} as const;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
kind: "collectionType",
|
|
3
|
+
collectionName: "canvas_plugin_sites",
|
|
4
|
+
info: {
|
|
5
|
+
singularName: "canvas-site",
|
|
6
|
+
pluralName: "canvas-sites",
|
|
7
|
+
displayName: "Canvas site",
|
|
8
|
+
},
|
|
9
|
+
options: { draftAndPublish: false },
|
|
10
|
+
pluginOptions: {
|
|
11
|
+
"content-manager": { visible: false },
|
|
12
|
+
"content-type-builder": { visible: false },
|
|
13
|
+
},
|
|
14
|
+
attributes: {
|
|
15
|
+
siteKey: { type: "string", required: true, unique: true, configurable: false },
|
|
16
|
+
docVersion: { type: "integer", required: true, default: 0, configurable: false },
|
|
17
|
+
document: { type: "json", required: true, configurable: false },
|
|
18
|
+
},
|
|
19
|
+
} as const;
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CanvasPage,
|
|
3
|
+
EditorShellBootstrap,
|
|
4
|
+
EditorShellContent,
|
|
5
|
+
EditorShellRoute,
|
|
6
|
+
EditorShellSharedBlock,
|
|
7
|
+
EditorShellSiteMetadata,
|
|
8
|
+
EditorShellTranslations,
|
|
9
|
+
} from "@batterai/editor-shell";
|
|
10
|
+
import { parseCanvasDocument, type CanvasDocument, type CanvasHostResult, type HostPageState } from "@batterai/contract";
|
|
11
|
+
import { CANVAS_PAGE_PREFIX, canvasPageKey } from "../../constants";
|
|
12
|
+
import { evaluateStrapiCanvasLicense } from "../license";
|
|
13
|
+
import { strapiCanvasConcurrency } from "../services/document-command-service";
|
|
14
|
+
import { CanvasPageValidationError } from "../services/page-doc-store";
|
|
15
|
+
import {
|
|
16
|
+
defaultHomePage,
|
|
17
|
+
defaultSiteDocument,
|
|
18
|
+
normalizeLibrary,
|
|
19
|
+
type CanvasSiteDocument,
|
|
20
|
+
} from "../services/site-store";
|
|
21
|
+
|
|
22
|
+
type KoaContext = {
|
|
23
|
+
params: Record<string, string | undefined>;
|
|
24
|
+
query: Record<string, unknown>;
|
|
25
|
+
get?: (name: string) => string;
|
|
26
|
+
request: {
|
|
27
|
+
body?: unknown;
|
|
28
|
+
files?: unknown;
|
|
29
|
+
header?: Record<string, string | string[] | undefined>;
|
|
30
|
+
headers?: Record<string, string | string[] | undefined>;
|
|
31
|
+
};
|
|
32
|
+
body?: unknown;
|
|
33
|
+
status?: number;
|
|
34
|
+
state?: { user?: { id: string | number; firstname?: string; lastname?: string; username?: string } };
|
|
35
|
+
badRequest?: (message: string) => void;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function pluginService(strapi: any, serviceName: string) {
|
|
39
|
+
return strapi.plugin("canvas").service(serviceName);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function bodyRecord(ctx: KoaContext): Record<string, unknown> {
|
|
43
|
+
const body = ctx.request.body;
|
|
44
|
+
return body && typeof body === "object" && !Array.isArray(body) ? body as Record<string, unknown> : {};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sitePages(site: CanvasSiteDocument, rows: Array<{ document?: { page?: CanvasPage }; docVersion?: number }>) {
|
|
48
|
+
const byId = new Map<string, CanvasPage>();
|
|
49
|
+
const pageVersions: Record<string, number> = {};
|
|
50
|
+
for (const row of rows) {
|
|
51
|
+
const page = row.document?.page;
|
|
52
|
+
if (!page?.id) continue;
|
|
53
|
+
byId.set(page.id, page);
|
|
54
|
+
pageVersions[page.id] = row.docVersion ?? 0;
|
|
55
|
+
}
|
|
56
|
+
if (!byId.size) {
|
|
57
|
+
const home = defaultHomePage();
|
|
58
|
+
byId.set(home.id, home);
|
|
59
|
+
pageVersions[home.id] = 0;
|
|
60
|
+
}
|
|
61
|
+
const ordered = site.pageOrder.map((id) => byId.get(id)).filter(Boolean) as CanvasPage[];
|
|
62
|
+
const orderedIds = new Set(ordered.map((page) => page.id));
|
|
63
|
+
const extras = [...byId.values()].filter((page) => !orderedIds.has(page.id));
|
|
64
|
+
return { pages: [...ordered, ...extras], pageVersions };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function canvasDocument(pages: readonly CanvasPage[], site: CanvasSiteDocument): CanvasDocument {
|
|
68
|
+
const canvas = site.canvas ?? defaultSiteDocument().canvas;
|
|
69
|
+
return {
|
|
70
|
+
schemaVersion: 1,
|
|
71
|
+
pages,
|
|
72
|
+
...canvas,
|
|
73
|
+
layout: canvas.layout ?? { pageOrder: pages.map((page) => page.id as never) },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type PageRow = {
|
|
78
|
+
docVersion?: number;
|
|
79
|
+
document?: {
|
|
80
|
+
page?: CanvasPage;
|
|
81
|
+
updatedAt?: string;
|
|
82
|
+
staged?: { page?: CanvasPage; stagedAt?: string; sourceDocVersion?: number };
|
|
83
|
+
published?: { page?: CanvasPage; publishedAt?: string; sourceDocVersion?: number };
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
function hostPageStates(rows: PageRow[], pages: readonly CanvasPage[]): Record<string, HostPageState> {
|
|
88
|
+
const rowsById = new Map(rows.flatMap((row) => row.document?.page?.id ? [[row.document.page.id, row] as const] : []));
|
|
89
|
+
return Object.fromEntries(pages.map((page) => {
|
|
90
|
+
const row = rowsById.get(page.id);
|
|
91
|
+
const published = row?.document?.published;
|
|
92
|
+
const staged = row?.document?.staged;
|
|
93
|
+
const draftDiffersFromPublished = Boolean(published?.page && JSON.stringify(page) !== JSON.stringify(published.page));
|
|
94
|
+
const draftDiffersFromStaged = Boolean(staged?.page && JSON.stringify(page) !== JSON.stringify(staged.page));
|
|
95
|
+
const workflowLabel = staged
|
|
96
|
+
? (draftDiffersFromStaged ? "Staged · newer draft changes" : "Staged")
|
|
97
|
+
: published
|
|
98
|
+
? (draftDiffersFromPublished ? "Draft changes" : undefined)
|
|
99
|
+
: "Not published";
|
|
100
|
+
return [page.id, {
|
|
101
|
+
publication: published ? "published" as const : "draft" as const,
|
|
102
|
+
hasPublishedVersion: Boolean(published),
|
|
103
|
+
hasStagedVersion: Boolean(staged),
|
|
104
|
+
draftDiffersFromPublished,
|
|
105
|
+
draftDiffersFromStaged,
|
|
106
|
+
...(workflowLabel ? { workflowLabel } : {}),
|
|
107
|
+
...(published?.publishedAt ? { lastSavedLabel: published.publishedAt } : {}),
|
|
108
|
+
}];
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function pageRows(strapi: any) {
|
|
113
|
+
const rows = await strapi.entityService.findMany("plugin::canvas.canvas-page", { limit: 1000 });
|
|
114
|
+
return rows.filter((row: { pageKey?: unknown }) =>
|
|
115
|
+
typeof row.pageKey === "string" && row.pageKey.startsWith(CANVAS_PAGE_PREFIX)
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function applyContentPatch(content: EditorShellContent, patch: Partial<EditorShellContent>): EditorShellContent {
|
|
120
|
+
return {
|
|
121
|
+
...content,
|
|
122
|
+
...patch,
|
|
123
|
+
media: patch.media ?? content.media ?? [],
|
|
124
|
+
routes: patch.routes ?? content.routes ?? [],
|
|
125
|
+
sharedBlocks: patch.sharedBlocks ?? content.sharedBlocks ?? [],
|
|
126
|
+
siteMetadata: patch.siteMetadata ?? content.siteMetadata ?? {},
|
|
127
|
+
translations: patch.translations ?? content.translations ?? {},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
132
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function publishedSiteSnapshot(site: CanvasSiteDocument): CanvasSiteDocument["published"] | undefined {
|
|
136
|
+
const snapshot = site.published;
|
|
137
|
+
if (!snapshot || !isRecord(snapshot.content) || !isRecord(snapshot.library)) return undefined;
|
|
138
|
+
if (typeof snapshot.publishedAt !== "string" || !Number.isInteger(snapshot.sourceDocVersion)) return undefined;
|
|
139
|
+
const parsed = parseCanvasDocument(snapshot.document);
|
|
140
|
+
if (!parsed.ok) return undefined;
|
|
141
|
+
return { ...snapshot, document: parsed.document };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function emptyPublicDelivery(site: CanvasSiteDocument) {
|
|
145
|
+
const defaults = defaultSiteDocument();
|
|
146
|
+
return {
|
|
147
|
+
content: { ...defaults.content, routes: [] },
|
|
148
|
+
library: defaults.library,
|
|
149
|
+
document: {
|
|
150
|
+
...canvasDocument([], defaults),
|
|
151
|
+
layout: { pageOrder: [] },
|
|
152
|
+
},
|
|
153
|
+
status: {
|
|
154
|
+
...site.status,
|
|
155
|
+
connected: true,
|
|
156
|
+
lastSyncedAt: new Date().toISOString(),
|
|
157
|
+
runtimeModel: "strapi-public-delivery",
|
|
158
|
+
source: "plugin::canvas.delivery",
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function commandResponse(ctx: KoaContext, result: CanvasHostResult<unknown>): void {
|
|
164
|
+
if (!result.ok) {
|
|
165
|
+
ctx.status = result.error.kind === "conflict" ? 409
|
|
166
|
+
: result.error.kind === "validation" ? 400
|
|
167
|
+
: result.error.kind === "forbidden" ? 403
|
|
168
|
+
: result.error.kind === "not-found" ? 404
|
|
169
|
+
: result.error.kind === "unsupported" ? 501
|
|
170
|
+
: 503;
|
|
171
|
+
}
|
|
172
|
+
ctx.body = result;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export default ({ strapi }: { strapi: any }) => ({
|
|
176
|
+
async collabToken(ctx: KoaContext) {
|
|
177
|
+
const service = pluginService(strapi, "collaboration");
|
|
178
|
+
if (!service) {
|
|
179
|
+
ctx.status = 404;
|
|
180
|
+
ctx.body = { error: "Collaboration is not enabled." };
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const user = ctx.state?.user;
|
|
184
|
+
if (!user) {
|
|
185
|
+
ctx.status = 401;
|
|
186
|
+
ctx.body = { error: "Authenticated Strapi admin required." };
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
ctx.body = service.mintToken(user);
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
async collab(ctx: KoaContext) {
|
|
193
|
+
const service = pluginService(strapi, "collaboration");
|
|
194
|
+
if (!service) {
|
|
195
|
+
ctx.status = 404;
|
|
196
|
+
ctx.body = { error: "Collaboration is not enabled." };
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const action = ctx.params.action;
|
|
200
|
+
if (!action) throw new CanvasPageValidationError("Collaboration action is required.");
|
|
201
|
+
const body = bodyRecord(ctx);
|
|
202
|
+
const user = ctx.state?.user;
|
|
203
|
+
if (!user) {
|
|
204
|
+
ctx.status = 401;
|
|
205
|
+
ctx.body = { error: "Authenticated Strapi admin required." };
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
if (action === "hello") {
|
|
210
|
+
if (typeof body.token !== "string" || typeof body.pageId !== "string") throw new CanvasPageValidationError("Collaboration hello is malformed.");
|
|
211
|
+
service.assertTokenOwner(body.token, user.id);
|
|
212
|
+
await service.ensurePage(body.pageId);
|
|
213
|
+
} else if (typeof body.sessionId === "string") {
|
|
214
|
+
service.assertSessionOwner(body.sessionId, user.id);
|
|
215
|
+
}
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (error instanceof CanvasPageValidationError) throw error;
|
|
218
|
+
ctx.status = 403;
|
|
219
|
+
ctx.body = { error: error instanceof Error ? error.message : "Collaboration access denied." };
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const originHeader = ctx.request.headers?.origin ?? ctx.request.header?.origin;
|
|
223
|
+
const origin = Array.isArray(originHeader)
|
|
224
|
+
? originHeader[0]
|
|
225
|
+
: originHeader ?? (typeof body.__canvasOrigin === "string" ? body.__canvasOrigin : undefined);
|
|
226
|
+
const response = await service.handler(new Request(`http://canvas.local/collab/${encodeURIComponent(action)}`, {
|
|
227
|
+
method: "POST",
|
|
228
|
+
headers: {
|
|
229
|
+
"content-type": "application/json",
|
|
230
|
+
...(origin ? { origin } : {}),
|
|
231
|
+
},
|
|
232
|
+
body: JSON.stringify(body),
|
|
233
|
+
}));
|
|
234
|
+
if (!response) {
|
|
235
|
+
ctx.status = 404;
|
|
236
|
+
ctx.body = { error: "Unknown collaboration action." };
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
ctx.status = response.status;
|
|
240
|
+
ctx.body = await response.json();
|
|
241
|
+
if (action === "hello" && (ctx.body as any)?.ok && (ctx.body as any)?.welcome?.sessionId) {
|
|
242
|
+
service.claimSession((ctx.body as any).welcome.sessionId, user.id);
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
async collabAudit(ctx: KoaContext) {
|
|
247
|
+
const service = pluginService(strapi, "collaboration");
|
|
248
|
+
if (!service) {
|
|
249
|
+
ctx.status = 404;
|
|
250
|
+
ctx.body = { error: "Collaboration is not enabled." };
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const canvasPageId = ctx.params.pageId;
|
|
254
|
+
if (!canvasPageId) throw new CanvasPageValidationError("Audit page id is required.");
|
|
255
|
+
ctx.body = { events: await service.readAudit(canvasPageId) };
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
async delivery(ctx: KoaContext) {
|
|
259
|
+
const site = await pluginService(strapi, "siteStore").readOrCreate();
|
|
260
|
+
const published = publishedSiteSnapshot(site);
|
|
261
|
+
if (!published) {
|
|
262
|
+
ctx.body = emptyPublicDelivery(site);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const documentService = pluginService(strapi, "documentService");
|
|
266
|
+
const config = strapi.config.get("plugin::canvas") ?? {};
|
|
267
|
+
const boundContent: Record<string, unknown[]> = {};
|
|
268
|
+
for (const collection of documentService.collectionNames?.() ?? Object.keys(config.boundCollections ?? {})) {
|
|
269
|
+
boundContent[collection] = await documentService.listCollection(collection, { status: "published" });
|
|
270
|
+
}
|
|
271
|
+
ctx.body = {
|
|
272
|
+
content: applyContentPatch(published.content, boundContent),
|
|
273
|
+
library: published.library,
|
|
274
|
+
document: published.document,
|
|
275
|
+
status: {
|
|
276
|
+
...site.status,
|
|
277
|
+
connected: true,
|
|
278
|
+
lastSyncedAt: new Date().toISOString(),
|
|
279
|
+
runtimeModel: "strapi-public-delivery",
|
|
280
|
+
source: "plugin::canvas.delivery",
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
async bootstrap(ctx: KoaContext) {
|
|
286
|
+
const site = await pluginService(strapi, "siteStore").readOrCreate();
|
|
287
|
+
const rows = await pageRows(strapi);
|
|
288
|
+
const { pages, pageVersions } = sitePages(site, rows);
|
|
289
|
+
const collaboration = pluginService(strapi, "collaboration");
|
|
290
|
+
if (collaboration) {
|
|
291
|
+
await Promise.all(pages.map((page) => collaboration.ensurePage(page.id)));
|
|
292
|
+
}
|
|
293
|
+
const documentService = pluginService(strapi, "documentService");
|
|
294
|
+
const config = strapi.config.get("plugin::canvas") ?? {};
|
|
295
|
+
const configuredLibrary = normalizeLibrary(config.seedLibrary);
|
|
296
|
+
const license = await evaluateStrapiCanvasLicense(config, ctx);
|
|
297
|
+
const boundContent: Record<string, unknown[]> = {};
|
|
298
|
+
for (const collection of documentService.collectionNames?.() ?? Object.keys(config.boundCollections ?? {})) {
|
|
299
|
+
boundContent[collection] = await documentService.listCollection(collection);
|
|
300
|
+
}
|
|
301
|
+
ctx.body = {
|
|
302
|
+
content: applyContentPatch(site.content, boundContent),
|
|
303
|
+
contentTypes: documentService.listContentTypes?.() ?? site.contentTypes,
|
|
304
|
+
library: configuredLibrary ?? site.library,
|
|
305
|
+
document: canvasDocument(pages, site),
|
|
306
|
+
pageStates: hostPageStates(rows, pages),
|
|
307
|
+
capabilities: {
|
|
308
|
+
canSave: true,
|
|
309
|
+
canPublish: true,
|
|
310
|
+
supportsPageStaging: true,
|
|
311
|
+
canUploadAssets: false,
|
|
312
|
+
canEditRoutes: true,
|
|
313
|
+
supportsPreview: true,
|
|
314
|
+
supportsAtomicMultiPageSave: true,
|
|
315
|
+
},
|
|
316
|
+
documentConcurrency: strapiCanvasConcurrency(site.docVersion),
|
|
317
|
+
pageVersions,
|
|
318
|
+
status: {
|
|
319
|
+
...site.status,
|
|
320
|
+
connected: true,
|
|
321
|
+
license,
|
|
322
|
+
lastSyncedAt: new Date().toISOString(),
|
|
323
|
+
},
|
|
324
|
+
} satisfies EditorShellBootstrap & { pageVersions: Record<string, number> };
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
async findPage(ctx: KoaContext) {
|
|
328
|
+
const pageId = ctx.params.pageId;
|
|
329
|
+
if (!pageId) throw new CanvasPageValidationError("pageId is required.");
|
|
330
|
+
const row = await pluginService(strapi, "pageDocStore").read(canvasPageKey(pageId));
|
|
331
|
+
ctx.body = row?.document ?? null;
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
async findPageRevision(ctx: KoaContext) {
|
|
335
|
+
const pageId = ctx.params.pageId;
|
|
336
|
+
const revision = ctx.params.revision;
|
|
337
|
+
if (!pageId) throw new CanvasPageValidationError("pageId is required.");
|
|
338
|
+
if (revision !== "draft" && revision !== "staged" && revision !== "published") {
|
|
339
|
+
throw new CanvasPageValidationError("revision must be draft, staged, or published.");
|
|
340
|
+
}
|
|
341
|
+
const row = await pluginService(strapi, "pageDocStore").read(canvasPageKey(pageId));
|
|
342
|
+
const page = revision === "draft" ? row?.document?.page
|
|
343
|
+
: revision === "staged" ? row?.document?.staged?.page
|
|
344
|
+
: row?.document?.published?.page;
|
|
345
|
+
if (!page) {
|
|
346
|
+
ctx.status = 404;
|
|
347
|
+
ctx.body = { ok: false, error: { kind: "not-found" } };
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const createdAt = revision === "draft" ? row?.document?.updatedAt
|
|
351
|
+
: revision === "staged" ? row?.document?.staged?.stagedAt
|
|
352
|
+
: row?.document?.published?.publishedAt;
|
|
353
|
+
ctx.body = { ok: true, value: { kind: revision, page, ...(createdAt ? { createdAt } : {}) } };
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
async saveDocument(ctx: KoaContext) {
|
|
357
|
+
const result = await pluginService(strapi, "documentCommand").saveDocument(bodyRecord(ctx));
|
|
358
|
+
commandResponse(ctx, result);
|
|
359
|
+
},
|
|
360
|
+
|
|
361
|
+
async publishDocument(ctx: KoaContext) {
|
|
362
|
+
const result = await pluginService(strapi, "documentCommand").publishDocument(bodyRecord(ctx));
|
|
363
|
+
commandResponse(ctx, result);
|
|
364
|
+
},
|
|
365
|
+
|
|
366
|
+
async stagePage(ctx: KoaContext) {
|
|
367
|
+
const result = await pluginService(strapi, "documentCommand").stagePage({
|
|
368
|
+
...bodyRecord(ctx),
|
|
369
|
+
pageId: ctx.params.pageId,
|
|
370
|
+
});
|
|
371
|
+
commandResponse(ctx, result);
|
|
372
|
+
},
|
|
373
|
+
|
|
374
|
+
async unstagePage(ctx: KoaContext) {
|
|
375
|
+
const result = await pluginService(strapi, "documentCommand").unstagePage({
|
|
376
|
+
...bodyRecord(ctx),
|
|
377
|
+
pageId: ctx.params.pageId,
|
|
378
|
+
});
|
|
379
|
+
commandResponse(ctx, result);
|
|
380
|
+
},
|
|
381
|
+
|
|
382
|
+
async saveContent(ctx: KoaContext) {
|
|
383
|
+
const collection = ctx.params.collection;
|
|
384
|
+
if (!collection) throw new CanvasPageValidationError("collection is required.");
|
|
385
|
+
const items = bodyRecord(ctx).items;
|
|
386
|
+
if (!Array.isArray(items)) throw new CanvasPageValidationError("items must be an array.");
|
|
387
|
+
await pluginService(strapi, "documentService").saveCollection(collection, items);
|
|
388
|
+
ctx.body = { saved: true };
|
|
389
|
+
},
|
|
390
|
+
|
|
391
|
+
async listMedia(ctx: KoaContext) {
|
|
392
|
+
ctx.body = { items: [], nextCursor: undefined };
|
|
393
|
+
},
|
|
394
|
+
|
|
395
|
+
async uploadMedia(ctx: KoaContext) {
|
|
396
|
+
throw new CanvasPageValidationError("Canvas media upload is not configured for this Strapi plugin instance.");
|
|
397
|
+
},
|
|
398
|
+
|
|
399
|
+
async saveMedia(ctx: KoaContext) {
|
|
400
|
+
const media = bodyRecord(ctx).media;
|
|
401
|
+
await updateSite(strapi, (site) => ({
|
|
402
|
+
...site,
|
|
403
|
+
docVersion: site.docVersion + 1,
|
|
404
|
+
content: applyContentPatch(site.content, { media: Array.isArray(media) ? media as never : [] }),
|
|
405
|
+
}));
|
|
406
|
+
ctx.body = { saved: true };
|
|
407
|
+
},
|
|
408
|
+
|
|
409
|
+
async saveRoutes(ctx: KoaContext) {
|
|
410
|
+
const routes = bodyRecord(ctx).routes;
|
|
411
|
+
await updateSite(strapi, (site) => ({
|
|
412
|
+
...site,
|
|
413
|
+
docVersion: site.docVersion + 1,
|
|
414
|
+
content: applyContentPatch(site.content, { routes: Array.isArray(routes) ? routes as EditorShellRoute[] : [] }),
|
|
415
|
+
}));
|
|
416
|
+
ctx.body = { saved: true };
|
|
417
|
+
},
|
|
418
|
+
|
|
419
|
+
async saveSharedBlocks(ctx: KoaContext) {
|
|
420
|
+
const sharedBlocks = bodyRecord(ctx).sharedBlocks;
|
|
421
|
+
await updateSite(strapi, (site) => ({
|
|
422
|
+
...site,
|
|
423
|
+
docVersion: site.docVersion + 1,
|
|
424
|
+
content: applyContentPatch(site.content, {
|
|
425
|
+
sharedBlocks: Array.isArray(sharedBlocks) ? sharedBlocks as EditorShellSharedBlock[] : [],
|
|
426
|
+
}),
|
|
427
|
+
}));
|
|
428
|
+
ctx.body = { saved: true };
|
|
429
|
+
},
|
|
430
|
+
|
|
431
|
+
async saveTranslations(ctx: KoaContext) {
|
|
432
|
+
const translations = bodyRecord(ctx).translations;
|
|
433
|
+
await updateSite(strapi, (site) => ({
|
|
434
|
+
...site,
|
|
435
|
+
docVersion: site.docVersion + 1,
|
|
436
|
+
content: applyContentPatch(site.content, {
|
|
437
|
+
translations: translations && typeof translations === "object"
|
|
438
|
+
? translations as EditorShellTranslations
|
|
439
|
+
: {},
|
|
440
|
+
}),
|
|
441
|
+
}));
|
|
442
|
+
ctx.body = { saved: true };
|
|
443
|
+
},
|
|
444
|
+
|
|
445
|
+
async saveSiteMetadata(ctx: KoaContext) {
|
|
446
|
+
const siteMetadata = bodyRecord(ctx).siteMetadata;
|
|
447
|
+
await updateSite(strapi, (site) => ({
|
|
448
|
+
...site,
|
|
449
|
+
docVersion: site.docVersion + 1,
|
|
450
|
+
content: applyContentPatch(site.content, {
|
|
451
|
+
siteMetadata: siteMetadata && typeof siteMetadata === "object"
|
|
452
|
+
? siteMetadata as EditorShellSiteMetadata
|
|
453
|
+
: {},
|
|
454
|
+
}),
|
|
455
|
+
}));
|
|
456
|
+
ctx.body = { saved: true };
|
|
457
|
+
},
|
|
458
|
+
|
|
459
|
+
async savePreset(ctx: KoaContext) {
|
|
460
|
+
const preset = bodyRecord(ctx).preset;
|
|
461
|
+
const site = await pluginService(strapi, "siteStore").readOrCreate();
|
|
462
|
+
ctx.body = { library: site.library, preset };
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
async function updateSite(strapi: any, mutator: (site: CanvasSiteDocument) => CanvasSiteDocument) {
|
|
467
|
+
await pluginService(strapi, "siteStore").update(mutator);
|
|
468
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import config from "./config";
|
|
2
|
+
import bootstrap from "./bootstrap";
|
|
3
|
+
import register from "./register";
|
|
4
|
+
import contentTypes from "./content-types";
|
|
5
|
+
import controllers from "./controllers";
|
|
6
|
+
import policies from "./policies/is-admin-authenticated";
|
|
7
|
+
import routes from "./routes";
|
|
8
|
+
import services from "./services";
|
|
9
|
+
|
|
10
|
+
export default () => ({
|
|
11
|
+
register,
|
|
12
|
+
bootstrap,
|
|
13
|
+
config,
|
|
14
|
+
routes,
|
|
15
|
+
controllers,
|
|
16
|
+
contentTypes,
|
|
17
|
+
policies: {
|
|
18
|
+
"is-admin-authenticated": policies,
|
|
19
|
+
},
|
|
20
|
+
services,
|
|
21
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {
|
|
2
|
+
evaluateBatteraiAuthoringLicense,
|
|
3
|
+
type LicenseEvaluation,
|
|
4
|
+
} from "@batterai/trust";
|
|
5
|
+
|
|
6
|
+
export type CanvasLicenseConfig = {
|
|
7
|
+
licenseKey?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type CanvasLicenseContext = {
|
|
11
|
+
get?: (name: string) => string;
|
|
12
|
+
request?: {
|
|
13
|
+
header?: Record<string, string | string[] | undefined>;
|
|
14
|
+
headers?: Record<string, string | string[] | undefined>;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export async function evaluateStrapiCanvasLicense(
|
|
19
|
+
config: CanvasLicenseConfig,
|
|
20
|
+
ctx: CanvasLicenseContext,
|
|
21
|
+
): Promise<LicenseEvaluation> {
|
|
22
|
+
return evaluateBatteraiAuthoringLicense({
|
|
23
|
+
hostname: requestHostname(ctx),
|
|
24
|
+
licenseKey: config.licenseKey,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function requestHostname(ctx: CanvasLicenseContext): string {
|
|
29
|
+
const forwarded = headerValue(ctx, "x-forwarded-host");
|
|
30
|
+
const host = forwarded?.split(",")[0]?.trim() || headerValue(ctx, "host");
|
|
31
|
+
return host ?? "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function headerValue(ctx: CanvasLicenseContext, name: string): string | undefined {
|
|
35
|
+
const fromGetter = ctx.get?.(name);
|
|
36
|
+
if (fromGetter) return fromGetter;
|
|
37
|
+
const lower = name.toLowerCase();
|
|
38
|
+
const headers = ctx.request?.headers ?? ctx.request?.header ?? {};
|
|
39
|
+
const value = headers[lower] ?? headers[name];
|
|
40
|
+
if (Array.isArray(value)) return value[0];
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { CANVAS_PERMISSIONS } from "../../constants";
|
|
2
|
+
|
|
3
|
+
function permissions(actions: readonly string[]) {
|
|
4
|
+
return [
|
|
5
|
+
"admin::isAuthenticatedAdmin",
|
|
6
|
+
{
|
|
7
|
+
name: "admin::hasPermissions",
|
|
8
|
+
config: { actions },
|
|
9
|
+
},
|
|
10
|
+
];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const read = permissions([CANVAS_PERMISSIONS.access]);
|
|
14
|
+
const write = permissions([CANVAS_PERMISSIONS.write]);
|
|
15
|
+
const audit = permissions([CANVAS_PERMISSIONS.audit]);
|
|
16
|
+
|
|
17
|
+
export default {
|
|
18
|
+
type: "admin",
|
|
19
|
+
routes: [
|
|
20
|
+
{ method: "GET", path: "/bootstrap", handler: "canvas.bootstrap", config: { policies: read } },
|
|
21
|
+
{ method: "POST", path: "/collab-token", handler: "canvas.collabToken", config: { policies: write } },
|
|
22
|
+
{ method: "POST", path: "/collab/:action", handler: "canvas.collab", config: { policies: write } },
|
|
23
|
+
{ method: "GET", path: "/collab-audit/:pageId", handler: "canvas.collabAudit", config: { policies: audit } },
|
|
24
|
+
{ method: "GET", path: "/pages/:pageId", handler: "canvas.findPage", config: { policies: read } },
|
|
25
|
+
{ method: "GET", path: "/pages/:pageId/revisions/:revision", handler: "canvas.findPageRevision", config: { policies: read } },
|
|
26
|
+
{ method: "POST", path: "/pages/:pageId/stage", handler: "canvas.stagePage", config: { policies: write } },
|
|
27
|
+
{ method: "POST", path: "/pages/:pageId/unstage", handler: "canvas.unstagePage", config: { policies: write } },
|
|
28
|
+
{ method: "PUT", path: "/document", handler: "canvas.saveDocument", config: { policies: write } },
|
|
29
|
+
{ method: "POST", path: "/document/publish", handler: "canvas.publishDocument", config: { policies: write } },
|
|
30
|
+
{ method: "PUT", path: "/content/:collection", handler: "canvas.saveContent", config: { policies: write } },
|
|
31
|
+
{ method: "GET", path: "/media", handler: "canvas.listMedia", config: { policies: read } },
|
|
32
|
+
{ method: "POST", path: "/media", handler: "canvas.uploadMedia", config: { policies: write } },
|
|
33
|
+
{ method: "PUT", path: "/media", handler: "canvas.saveMedia", config: { policies: write } },
|
|
34
|
+
{ method: "PUT", path: "/routes", handler: "canvas.saveRoutes", config: { policies: write } },
|
|
35
|
+
{ method: "PUT", path: "/shared-blocks", handler: "canvas.saveSharedBlocks", config: { policies: write } },
|
|
36
|
+
{ method: "PUT", path: "/translations", handler: "canvas.saveTranslations", config: { policies: write } },
|
|
37
|
+
{ method: "PUT", path: "/site-metadata", handler: "canvas.saveSiteMetadata", config: { policies: write } },
|
|
38
|
+
{ method: "POST", path: "/presets", handler: "canvas.savePreset", config: { policies: write } },
|
|
39
|
+
],
|
|
40
|
+
};
|