@natilon/cms-server 0.1.2 → 0.3.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/package.json +8 -4
- package/src/adapters/_shared.mjs +125 -0
- package/src/adapters/basic-auth.mjs +9 -2
- package/src/adapters/cdn-proxy-media.mjs +3 -4
- package/src/adapters/fs-json-content.mjs +17 -60
- package/src/adapters/fs-templates.mjs +57 -0
- package/src/adapters/github-api.mjs +91 -0
- package/src/adapters/github-content.mjs +142 -101
- package/src/adapters/github-oauth.mjs +40 -57
- package/src/adapters/github-templates.mjs +100 -0
- package/src/adapters/index.mjs +2 -0
- package/src/adapters/local-assets-media.mjs +1 -4
- package/src/adapters/types.mjs +16 -0
- package/src/default-public-config.mjs +15 -12
- package/src/express-shim.mjs +51 -0
- package/src/hono-shim.mjs +108 -0
- package/src/index.mjs +2 -0
- package/src/routes.mjs +394 -0
- package/src/server.mjs +75 -215
package/src/server.mjs
CHANGED
|
@@ -3,6 +3,8 @@ import fs from "fs";
|
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { resolveAdminUiDir } from "./admin-ui-path.mjs";
|
|
5
5
|
import { defaultPublicConfig } from "./default-public-config.mjs";
|
|
6
|
+
import { allRoutes } from "./routes.mjs";
|
|
7
|
+
import { mountRoutes, buildPublicAllowlist } from "./express-shim.mjs";
|
|
6
8
|
import {
|
|
7
9
|
createFsJsonContent,
|
|
8
10
|
createGitHubContent,
|
|
@@ -11,6 +13,8 @@ import {
|
|
|
11
13
|
createBasicAuth,
|
|
12
14
|
createGitHubOAuth,
|
|
13
15
|
createNetlifyBuild,
|
|
16
|
+
createFsTemplates,
|
|
17
|
+
createGitHubTemplates,
|
|
14
18
|
} from "./adapters/index.mjs";
|
|
15
19
|
|
|
16
20
|
/**
|
|
@@ -23,8 +27,6 @@ import {
|
|
|
23
27
|
* @param {string} [opts.realm] HTTP basic-auth realm (default "CMS Admin")
|
|
24
28
|
*/
|
|
25
29
|
export function createCmsServer({ config, rootDir, publicConfig: publicConfigFn, realm = "CMS Admin" }) {
|
|
26
|
-
// Auto-derive publicConfig when the consumer doesn't provide one.
|
|
27
|
-
const publicConfig = publicConfigFn ?? (() => defaultPublicConfig(config));
|
|
28
30
|
const content =
|
|
29
31
|
config.content?.provider === "github"
|
|
30
32
|
? createGitHubContent({
|
|
@@ -78,162 +80,68 @@ export function createCmsServer({ config, rootDir, publicConfig: publicConfigFn,
|
|
|
78
80
|
defaultBranch: process.env.STAGING_BRANCH || config.content.publishBranch,
|
|
79
81
|
});
|
|
80
82
|
|
|
83
|
+
const templates =
|
|
84
|
+
config.content?.provider === "github"
|
|
85
|
+
? createGitHubTemplates({
|
|
86
|
+
token: process.env[config.content.githubTokenEnv || "GITHUB_TOKEN"],
|
|
87
|
+
owner: config.content.owner,
|
|
88
|
+
repo: config.content.repo,
|
|
89
|
+
branch: process.env.STAGING_BRANCH || config.content.branch || "main",
|
|
90
|
+
templatesDir: config.content.templatesDir,
|
|
91
|
+
commitMessage: config.content.commitMessage,
|
|
92
|
+
})
|
|
93
|
+
: createFsTemplates({ rootDir });
|
|
94
|
+
|
|
95
|
+
const adapters = {
|
|
96
|
+
content,
|
|
97
|
+
templates,
|
|
98
|
+
localMedia,
|
|
99
|
+
cdnMedia,
|
|
100
|
+
auth,
|
|
101
|
+
build,
|
|
102
|
+
publicConfig: () => (publicConfigFn ?? defaultPublicConfig)(config),
|
|
103
|
+
};
|
|
104
|
+
|
|
81
105
|
const app = express();
|
|
82
106
|
app.use(express.json({ limit: "10mb" }));
|
|
83
107
|
|
|
84
|
-
|
|
85
|
-
|
|
108
|
+
const corsOrigin = config.cors?.origin ?? "*";
|
|
109
|
+
if (corsOrigin === "*" && config.auth?.provider) {
|
|
110
|
+
console.warn(
|
|
111
|
+
"WARNING: CORS origin is '*' — any site can call this API in a logged-in user's browser. Set cors.origin in cms.config.mjs to lock down.",
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const corsAllowList = Array.isArray(corsOrigin) ? corsOrigin : [corsOrigin];
|
|
115
|
+
app.use((req, res, next) => {
|
|
116
|
+
const reqOrigin = req.headers.origin;
|
|
117
|
+
if (corsAllowList.includes("*")) {
|
|
118
|
+
res.header("Access-Control-Allow-Origin", "*");
|
|
119
|
+
} else if (reqOrigin && corsAllowList.includes(reqOrigin)) {
|
|
120
|
+
res.header("Access-Control-Allow-Origin", reqOrigin);
|
|
121
|
+
res.header("Vary", "Origin");
|
|
122
|
+
}
|
|
86
123
|
res.header("Access-Control-Allow-Methods", "GET,PUT,POST,DELETE,OPTIONS");
|
|
87
124
|
res.header("Access-Control-Allow-Headers", "Content-Type,Authorization");
|
|
88
|
-
if (
|
|
125
|
+
if (req.method === "OPTIONS") return res.sendStatus(204);
|
|
89
126
|
next();
|
|
90
127
|
});
|
|
91
128
|
|
|
92
|
-
|
|
93
|
-
if (auth.oauthRoutes) {
|
|
94
|
-
app.get("/admin/oauth/login", (req, res) => auth.oauthRoutes.login(req, res));
|
|
95
|
-
app.get("/admin/oauth/callback", (req, res) => auth.oauthRoutes.callback(req, res));
|
|
96
|
-
}
|
|
129
|
+
const routes = allRoutes(config);
|
|
97
130
|
|
|
98
131
|
if (auth.configured) {
|
|
132
|
+
const allow = buildPublicAllowlist(routes);
|
|
99
133
|
app.use(
|
|
100
134
|
auth.middlewareWith(
|
|
101
|
-
(req) =>
|
|
102
|
-
req.path.startsWith(`${localMedia.urlPrefix}/`) ||
|
|
103
|
-
req.path === "/api/config" ||
|
|
104
|
-
req.path.startsWith("/admin/oauth/"),
|
|
135
|
+
(req) => req.path.startsWith(`${localMedia.urlPrefix}/`) || allow(req),
|
|
105
136
|
),
|
|
106
137
|
);
|
|
107
138
|
} else {
|
|
108
139
|
console.warn(`WARNING: ${config.auth.passEnv} not set — running without authentication`);
|
|
109
140
|
}
|
|
110
141
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
function sanitizeSlug(s) {
|
|
114
|
-
return String(s).replace(/[^a-zA-Z0-9._-]/g, "");
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function requireAdmin(req, res, next) {
|
|
118
|
-
if (req.cmsUser && req.cmsUser.role !== "admin") {
|
|
119
|
-
return res.status(403).json({ error: "Admin role required" });
|
|
120
|
-
}
|
|
121
|
-
next();
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
app.get("/api/config", (_req, res) => {
|
|
125
|
-
const cfg = publicConfig ? publicConfig() : {};
|
|
126
|
-
// Always expose auth provider and previewUrlPattern to the browser
|
|
127
|
-
if (!cfg.auth) cfg.auth = {};
|
|
128
|
-
if (!cfg.auth.provider) cfg.auth.provider = config.auth?.provider || "basic";
|
|
129
|
-
if (!cfg.previewUrlPattern && config.previewUrlPattern) {
|
|
130
|
-
cfg.previewUrlPattern = config.previewUrlPattern;
|
|
131
|
-
}
|
|
132
|
-
res.json(cfg);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
app.get("/api/me", (req, res) => {
|
|
136
|
-
res.json(req.cmsUser || { login: "admin", role: "admin" });
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
app.get("/api/collections", async (_req, res) => {
|
|
140
|
-
res.json(await content.listCollections());
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
app.get("/api/collections/:collection", async (req, res) => {
|
|
144
|
-
const sortConfig = config.collections?.[req.params.collection]?.sort ?? null;
|
|
145
|
-
const pages = await content.listPages(req.params.collection, sortConfig);
|
|
146
|
-
if (pages === null) return res.status(404).json({ error: "Not found" });
|
|
147
|
-
res.json(pages);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
app.get("/api/collections/:collection/:file", async (req, res) => {
|
|
151
|
-
const data = await content.readPage(req.params.collection, req.params.file);
|
|
152
|
-
if (!data) return res.status(404).json({ error: "Not found" });
|
|
153
|
-
res.json(data);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
app.put("/api/collections/:collection/:file", async (req, res) => {
|
|
157
|
-
await content.writePage(req.params.collection, req.params.file, req.body);
|
|
158
|
-
res.json({ ok: true });
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
app.post("/api/collections/:collection", async (req, res) => {
|
|
162
|
-
const result = await content.createPage(req.params.collection, req.body);
|
|
163
|
-
res.json({ ok: true, file: result.file });
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
app.delete("/api/collections/:collection/:file", requireAdmin, async (req, res) => {
|
|
167
|
-
const ok = await content.deletePage(req.params.collection, req.params.file);
|
|
168
|
-
if (!ok) return res.status(404).json({ error: "Not found" });
|
|
169
|
-
res.json({ ok: true });
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
app.post("/api/collections/:collection/:file/duplicate", async (req, res) => {
|
|
173
|
-
const result = await content.duplicatePage(req.params.collection, req.params.file);
|
|
174
|
-
if (!result) return res.status(404).json({ error: "Not found" });
|
|
175
|
-
res.json({ ok: true, file: result.file });
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
app.get("/api/history/:collection/:file", async (req, res) => {
|
|
179
|
-
try {
|
|
180
|
-
res.json(await content.listHistory(req.params.collection, req.params.file));
|
|
181
|
-
} catch (err) {
|
|
182
|
-
res.status(500).json({ error: err.message });
|
|
183
|
-
}
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
app.post("/api/history/:collection/:file/:ts/restore", async (req, res) => {
|
|
187
|
-
try {
|
|
188
|
-
const ok = await content.restoreHistory(req.params.collection, req.params.file, req.params.ts);
|
|
189
|
-
if (!ok) return res.status(404).json({ error: "Revision not found" });
|
|
190
|
-
res.json({ ok: true });
|
|
191
|
-
} catch (err) {
|
|
192
|
-
res.status(500).json({ error: err.message });
|
|
193
|
-
}
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
app.get("/api/templates", (_req, res) => {
|
|
197
|
-
if (!fs.existsSync(TEMPLATES_DIR)) return res.json([]);
|
|
198
|
-
const files = fs.readdirSync(TEMPLATES_DIR).filter((f) => f.endsWith(".json"));
|
|
199
|
-
const list = files.map((f) => {
|
|
200
|
-
const data = JSON.parse(fs.readFileSync(path.join(TEMPLATES_DIR, f), "utf-8"));
|
|
201
|
-
return { name: data.name, slug: f.replace(".json", ""), blockCount: (data.blocks || []).length };
|
|
202
|
-
});
|
|
203
|
-
res.json(list);
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
app.get("/api/templates/:slug", (req, res) => {
|
|
207
|
-
const file = path.join(TEMPLATES_DIR, sanitizeSlug(req.params.slug) + ".json");
|
|
208
|
-
if (!fs.existsSync(file)) return res.status(404).json({ error: "Not found" });
|
|
209
|
-
res.json(JSON.parse(fs.readFileSync(file, "utf-8")));
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
app.post("/api/templates", (req, res) => {
|
|
213
|
-
const { name, blocks } = req.body;
|
|
214
|
-
if (!name || !blocks) return res.status(400).json({ error: "name and blocks required" });
|
|
215
|
-
if (!fs.existsSync(TEMPLATES_DIR)) fs.mkdirSync(TEMPLATES_DIR, { recursive: true });
|
|
216
|
-
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
217
|
-
const file = path.join(TEMPLATES_DIR, slug + ".json");
|
|
218
|
-
fs.writeFileSync(file, JSON.stringify({ name, blocks }, null, 2), "utf-8");
|
|
219
|
-
res.json({ ok: true, slug });
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
app.delete("/api/templates/:slug", requireAdmin, (req, res) => {
|
|
223
|
-
const file = path.join(TEMPLATES_DIR, sanitizeSlug(req.params.slug) + ".json");
|
|
224
|
-
if (!fs.existsSync(file)) return res.status(404).json({ error: "Not found" });
|
|
225
|
-
fs.unlinkSync(file);
|
|
226
|
-
res.json({ ok: true });
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
app.get("/api/assets", async (_req, res) => {
|
|
230
|
-
res.json(await localMedia.listGrouped());
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
app.get("/api/assets/:folder", async (req, res) => {
|
|
234
|
-
res.json(await localMedia.listFolder(req.params.folder));
|
|
235
|
-
});
|
|
142
|
+
mountRoutes(app, routes, { adapters, config });
|
|
236
143
|
|
|
144
|
+
// Local media static file serve (Express-only — Worker has no local fs).
|
|
237
145
|
app.get(`${localMedia.urlPrefix}/{*filepath}`, (req, res) => {
|
|
238
146
|
const sub = Array.isArray(req.params.filepath)
|
|
239
147
|
? req.params.filepath.join("/")
|
|
@@ -243,78 +151,15 @@ export function createCmsServer({ config, rootDir, publicConfig: publicConfigFn,
|
|
|
243
151
|
res.sendFile(full);
|
|
244
152
|
});
|
|
245
153
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
res.status(status >= 400 && status < 600 ? status : 502).json({
|
|
249
|
-
error: err.message,
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
app.get("/api/media/folders", async (_req, res) => {
|
|
254
|
-
try {
|
|
255
|
-
res.json(await cdnMedia.listFolders());
|
|
256
|
-
} catch (err) {
|
|
257
|
-
handleMediaError(res, err);
|
|
258
|
-
}
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
app.get("/api/media/folder/:folder", async (req, res) => {
|
|
262
|
-
try {
|
|
263
|
-
res.json(
|
|
264
|
-
await cdnMedia.listFolder(req.params.folder, {
|
|
265
|
-
page: parseInt(req.query.page) || 1,
|
|
266
|
-
perPage: parseInt(req.query.per_page) || 30,
|
|
267
|
-
search: req.query.search || "",
|
|
268
|
-
}),
|
|
269
|
-
);
|
|
270
|
-
} catch (err) {
|
|
271
|
-
handleMediaError(res, err);
|
|
272
|
-
}
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
app.post("/api/media/upload", async (req, res) => {
|
|
276
|
-
try {
|
|
277
|
-
res.json(await cdnMedia.upload(req.body));
|
|
278
|
-
} catch (err) {
|
|
279
|
-
handleMediaError(res, err);
|
|
280
|
-
}
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
app.post("/api/publish", requireAdmin, async (_req, res) => {
|
|
284
|
-
try {
|
|
285
|
-
const result = await content.publish();
|
|
286
|
-
res.json(result);
|
|
287
|
-
} catch (err) {
|
|
288
|
-
res.status(500).json({ ok: false, message: err.message });
|
|
289
|
-
}
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
app.get("/api/publish/status", async (_req, res) => {
|
|
293
|
-
try {
|
|
294
|
-
res.json(await content.pendingChanges());
|
|
295
|
-
} catch (err) {
|
|
296
|
-
res.status(500).json({ error: err.message });
|
|
297
|
-
}
|
|
154
|
+
app.get("/api/assets", async (_req, res) => {
|
|
155
|
+
res.json(await localMedia.listGrouped());
|
|
298
156
|
});
|
|
299
157
|
|
|
300
|
-
app.get("/api/
|
|
301
|
-
|
|
302
|
-
try {
|
|
303
|
-
res.json(
|
|
304
|
-
await build.getDeployStatus({
|
|
305
|
-
branch: req.query.branch,
|
|
306
|
-
sha: req.query.sha,
|
|
307
|
-
}),
|
|
308
|
-
);
|
|
309
|
-
} catch (err) {
|
|
310
|
-
if (err.upstreamStatus) {
|
|
311
|
-
return res.status(502).json({ configured: true, error: err.message });
|
|
312
|
-
}
|
|
313
|
-
res.status(500).json({ configured: true, error: err.message });
|
|
314
|
-
}
|
|
158
|
+
app.get("/api/assets/:folder", async (req, res) => {
|
|
159
|
+
res.json(await localMedia.listFolder(req.params.folder));
|
|
315
160
|
});
|
|
316
161
|
|
|
317
|
-
return { app, adapters: { content, localMedia, cdnMedia, auth, build } };
|
|
162
|
+
return { app, adapters: { content, templates, localMedia, cdnMedia, auth, build } };
|
|
318
163
|
}
|
|
319
164
|
|
|
320
165
|
/**
|
|
@@ -327,12 +172,13 @@ export function startScheduler(content, intervalMs = 60_000) {
|
|
|
327
172
|
try {
|
|
328
173
|
const due = await content.listScheduled();
|
|
329
174
|
if (!due.length) return;
|
|
330
|
-
|
|
175
|
+
const items = due.map(({ collection, file, data }) => {
|
|
331
176
|
delete data.meta.publishAt;
|
|
332
177
|
data.meta.draft = false;
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
178
|
+
return { collection, file, data };
|
|
179
|
+
});
|
|
180
|
+
await content.writeBatch(items, "[scheduler] Auto-publish");
|
|
181
|
+
console.log(`[scheduler] Auto-published ${items.length} item(s)`);
|
|
336
182
|
const result = await content.publish();
|
|
337
183
|
console.log("[scheduler] " + result.message);
|
|
338
184
|
} catch (err) {
|
|
@@ -355,10 +201,20 @@ export async function mountAdminUi(app, adminUi) {
|
|
|
355
201
|
const resolved =
|
|
356
202
|
adminUi.mode === "auto"
|
|
357
203
|
? process.env.NODE_ENV === "production"
|
|
358
|
-
? { mode: "static", dir: adminUi.distDir }
|
|
359
|
-
: { mode: "vite-dev", root: adminUi.sourceDir, base: adminUi.base || "/admin/" }
|
|
204
|
+
? { mode: "static", dir: adminUi.distDir, previewThemeCss: adminUi.previewThemeCss }
|
|
205
|
+
: { mode: "vite-dev", root: adminUi.sourceDir, base: adminUi.base || "/admin/", previewThemeCss: adminUi.previewThemeCss }
|
|
360
206
|
: adminUi;
|
|
361
207
|
|
|
208
|
+
// Optional user CSS override for in-canvas preview styling.
|
|
209
|
+
// Served before the static handler so it always responds (empty if unset),
|
|
210
|
+
// preventing a 404 from the <link> tag in index.html.
|
|
211
|
+
const themePath = resolved.previewThemeCss;
|
|
212
|
+
app.get("/admin/preview-theme.css", (_req, res) => {
|
|
213
|
+
res.type("text/css");
|
|
214
|
+
if (themePath && fs.existsSync(themePath)) res.sendFile(path.resolve(themePath));
|
|
215
|
+
else res.send("/* no preview-theme.css configured */");
|
|
216
|
+
});
|
|
217
|
+
|
|
362
218
|
if (resolved.mode === "static") {
|
|
363
219
|
if (!fs.existsSync(path.join(resolved.dir, "index.html"))) {
|
|
364
220
|
throw new Error(
|
|
@@ -395,14 +251,18 @@ export async function mountAdminUi(app, adminUi) {
|
|
|
395
251
|
export async function startCmsServer(opts) {
|
|
396
252
|
const { app, adapters } = createCmsServer(opts);
|
|
397
253
|
|
|
398
|
-
|
|
254
|
+
const previewThemeCss =
|
|
255
|
+
opts.config?.previewThemeCss
|
|
256
|
+
? path.resolve(opts.rootDir || process.cwd(), opts.config.previewThemeCss)
|
|
257
|
+
: null;
|
|
258
|
+
|
|
399
259
|
const adminUi = opts.adminUi ?? (() => {
|
|
400
260
|
const dir = resolveAdminUiDir();
|
|
401
261
|
if (!dir) {
|
|
402
262
|
console.warn("admin-ui dist not found — run `npm run build:admin-ui` first, or pass adminUi option explicitly.");
|
|
403
263
|
return null;
|
|
404
264
|
}
|
|
405
|
-
return { mode: "static", dir };
|
|
265
|
+
return { mode: "static", dir, previewThemeCss };
|
|
406
266
|
})();
|
|
407
267
|
|
|
408
268
|
await mountAdminUi(app, adminUi);
|