@natilon/cms-server 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/README.md +105 -0
- package/bin/natilon-cms.mjs +56 -0
- package/package.json +38 -0
- package/src/adapters/basic-auth.mjs +81 -0
- package/src/adapters/build-netlify.mjs +60 -0
- package/src/adapters/cdn-proxy-media.mjs +80 -0
- package/src/adapters/fs-json-content.mjs +248 -0
- package/src/adapters/github-content.mjs +272 -0
- package/src/adapters/github-oauth.mjs +174 -0
- package/src/adapters/index.mjs +8 -0
- package/src/adapters/local-assets-media.mjs +69 -0
- package/src/adapters/media-url.mjs +133 -0
- package/src/adapters/types.mjs +78 -0
- package/src/admin-ui-path.mjs +21 -0
- package/src/default-public-config.mjs +23 -0
- package/src/index.mjs +12 -0
- package/src/server.mjs +417 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter interface contracts for the natilon CMS core.
|
|
3
|
+
*
|
|
4
|
+
* These are JSDoc typedefs only — no runtime code. Concrete implementations
|
|
5
|
+
* (fs-json-content, local-assets-media, basic-auth, build-netlify) export
|
|
6
|
+
* factory functions that return objects matching these shapes.
|
|
7
|
+
*
|
|
8
|
+
* The admin server depends only on these interfaces, so swapping a backend
|
|
9
|
+
* (e.g. GitHub Contents API instead of local fs, S3 instead of local
|
|
10
|
+
* assets, OAuth instead of basic-auth) is a one-line config change.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Object} PageSummary
|
|
15
|
+
* @property {string} id
|
|
16
|
+
* @property {string} slug
|
|
17
|
+
* @property {string} lang
|
|
18
|
+
* @property {string} collection
|
|
19
|
+
* @property {string} title
|
|
20
|
+
* @property {string} file
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {Object} CollectionSummary
|
|
25
|
+
* @property {string} name
|
|
26
|
+
* @property {number} count
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {Object} PublishResult
|
|
31
|
+
* @property {boolean} ok
|
|
32
|
+
* @property {string} message
|
|
33
|
+
* @property {string} [sha]
|
|
34
|
+
* @property {string} [shortSha]
|
|
35
|
+
* @property {string} [branch]
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {Object} PendingChanges
|
|
40
|
+
* @property {boolean} hasChanges
|
|
41
|
+
* @property {number} changedFiles
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @typedef {Object} ContentAdapter
|
|
46
|
+
* @property {() => Promise<CollectionSummary[]>} listCollections
|
|
47
|
+
* @property {(collection: string) => Promise<PageSummary[]|null>} listPages
|
|
48
|
+
* @property {(collection: string, file: string) => Promise<object|null>} readPage
|
|
49
|
+
* @property {(collection: string, file: string, data: object) => Promise<void>} writePage
|
|
50
|
+
* @property {(collection: string, data: object) => Promise<{file: string}>} createPage
|
|
51
|
+
* @property {(collection: string, file: string) => Promise<boolean>} deletePage
|
|
52
|
+
* @property {(collection: string, file: string) => Promise<{file: string}|null>} duplicatePage
|
|
53
|
+
* @property {() => Promise<PendingChanges>} pendingChanges
|
|
54
|
+
* @property {() => Promise<PublishResult>} publish
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @typedef {Object} MediaAdapter
|
|
59
|
+
* @property {() => Promise<Record<string, string[]>>} listGrouped
|
|
60
|
+
* @property {(folder: string) => Promise<string[]>} listFolder
|
|
61
|
+
* @property {(relPath: string) => string|null} resolveLocalPath
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @typedef {Object} AuthAdapter
|
|
66
|
+
* @property {boolean} configured
|
|
67
|
+
* @property {import('express').RequestHandler} middleware
|
|
68
|
+
* @property {(allowlist: ((req: import('express').Request) => boolean)|null) => import('express').RequestHandler} middlewareWith
|
|
69
|
+
* @property {() => string} issueMediaToken
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @typedef {Object} BuildAdapter
|
|
74
|
+
* @property {boolean} configured
|
|
75
|
+
* @property {(opts?: {branch?: string, sha?: string}) => Promise<object>} getDeployStatus
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { fileURLToPath } from "url";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolve the absolute path to the built admin-ui dist directory.
|
|
9
|
+
*
|
|
10
|
+
* In the monorepo the layout is:
|
|
11
|
+
* packages/cms-server/src/ ← this file
|
|
12
|
+
* packages/admin-ui/dist/ ← bundled SPA
|
|
13
|
+
*
|
|
14
|
+
* Returns null when the dist hasn't been built yet (development without a
|
|
15
|
+
* prior `npm run build:admin-ui`). The server will throw a helpful error
|
|
16
|
+
* via mountAdminUi rather than crashing on a missing index.html.
|
|
17
|
+
*/
|
|
18
|
+
export function resolveAdminUiDir() {
|
|
19
|
+
const candidate = path.resolve(__dirname, "../../admin-ui/dist");
|
|
20
|
+
return fs.existsSync(path.join(candidate, "index.html")) ? candidate : null;
|
|
21
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive a safe browser-facing config from the full cms.config object.
|
|
3
|
+
*
|
|
4
|
+
* Never exposes: auth credentials env var names, build tokens, git config,
|
|
5
|
+
* GitHub OAuth secrets, content adapter internals.
|
|
6
|
+
*
|
|
7
|
+
* Used automatically by createCmsServer when the consumer does not supply
|
|
8
|
+
* their own publicConfig function.
|
|
9
|
+
*
|
|
10
|
+
* @param {Object} config Full cms.config object
|
|
11
|
+
* @returns {Object} Safe subset for the browser
|
|
12
|
+
*/
|
|
13
|
+
export function defaultPublicConfig(config) {
|
|
14
|
+
return {
|
|
15
|
+
mountPath: config.mountPath,
|
|
16
|
+
locales: config.locales,
|
|
17
|
+
defaultLocale: config.defaultLocale,
|
|
18
|
+
previewUrlPattern: config.previewUrlPattern,
|
|
19
|
+
media: config.media,
|
|
20
|
+
collections: config.collections,
|
|
21
|
+
blocks: config.blocks,
|
|
22
|
+
};
|
|
23
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { createCmsServer, mountAdminUi, startCmsServer, startScheduler } from "./server.mjs";
|
|
2
|
+
export { defaultPublicConfig } from "./default-public-config.mjs";
|
|
3
|
+
export {
|
|
4
|
+
createFsJsonContent,
|
|
5
|
+
createGitHubContent,
|
|
6
|
+
createLocalAssetsMedia,
|
|
7
|
+
createCdnProxyMedia,
|
|
8
|
+
createBasicAuth,
|
|
9
|
+
createGitHubOAuth,
|
|
10
|
+
createNetlifyBuild,
|
|
11
|
+
createMediaUrl,
|
|
12
|
+
} from "./adapters/index.mjs";
|
package/src/server.mjs
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { resolveAdminUiDir } from "./admin-ui-path.mjs";
|
|
5
|
+
import { defaultPublicConfig } from "./default-public-config.mjs";
|
|
6
|
+
import {
|
|
7
|
+
createFsJsonContent,
|
|
8
|
+
createGitHubContent,
|
|
9
|
+
createLocalAssetsMedia,
|
|
10
|
+
createCdnProxyMedia,
|
|
11
|
+
createBasicAuth,
|
|
12
|
+
createGitHubOAuth,
|
|
13
|
+
createNetlifyBuild,
|
|
14
|
+
} from "./adapters/index.mjs";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build a configured Express app for the CMS (no listen).
|
|
18
|
+
*
|
|
19
|
+
* @param {Object} opts
|
|
20
|
+
* @param {Object} opts.config cms.config object
|
|
21
|
+
* @param {string} opts.rootDir absolute path to consumer project root
|
|
22
|
+
* @param {() => Object} [opts.publicConfig] returns sanitized config for the browser
|
|
23
|
+
* @param {string} [opts.realm] HTTP basic-auth realm (default "CMS Admin")
|
|
24
|
+
*/
|
|
25
|
+
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
|
+
const content =
|
|
29
|
+
config.content?.provider === "github"
|
|
30
|
+
? createGitHubContent({
|
|
31
|
+
token: process.env[config.content.githubTokenEnv || "GITHUB_TOKEN"],
|
|
32
|
+
owner: config.content.owner,
|
|
33
|
+
repo: config.content.repo,
|
|
34
|
+
branch: process.env.STAGING_BRANCH || config.content.branch || "main",
|
|
35
|
+
pagesDir: config.content.pagesDir,
|
|
36
|
+
commitMessage: config.content.commitMessage,
|
|
37
|
+
})
|
|
38
|
+
: createFsJsonContent({
|
|
39
|
+
rootDir,
|
|
40
|
+
pagesDir: config.content.pagesDir,
|
|
41
|
+
publishBranch: process.env.STAGING_BRANCH || config.content.publishBranch,
|
|
42
|
+
publishPaths: config.content.publishPaths,
|
|
43
|
+
commitMessage: config.content.commitMessage,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const localMedia = createLocalAssetsMedia({
|
|
47
|
+
rootDir,
|
|
48
|
+
assetsDir: config.content.assetsDir,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const auth =
|
|
52
|
+
config.auth?.provider === "github-oauth"
|
|
53
|
+
? createGitHubOAuth({
|
|
54
|
+
clientId: process.env[config.auth.githubClientIdEnv || "GITHUB_CLIENT_ID"],
|
|
55
|
+
clientSecret: process.env[config.auth.githubClientSecretEnv || "GITHUB_CLIENT_SECRET"],
|
|
56
|
+
allowedLogins: config.auth.allowedLogins || [],
|
|
57
|
+
roles: config.auth.roles || {},
|
|
58
|
+
jwtSecret: process.env[config.auth.jwtSecretEnv],
|
|
59
|
+
jwtTtl: config.auth.jwtTtl,
|
|
60
|
+
realm,
|
|
61
|
+
})
|
|
62
|
+
: createBasicAuth({
|
|
63
|
+
user: process.env[config.auth.userEnv] || "admin",
|
|
64
|
+
pass: process.env[config.auth.passEnv],
|
|
65
|
+
jwtSecret: process.env[config.auth.jwtSecretEnv],
|
|
66
|
+
jwtTtl: config.auth.jwtTtl,
|
|
67
|
+
realm,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const cdnMedia = createCdnProxyMedia({
|
|
71
|
+
baseUrl: config.media.cdnBase,
|
|
72
|
+
getToken: () => auth.issueMediaToken(config.media.tenantId),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const build = createNetlifyBuild({
|
|
76
|
+
token: process.env[config.build?.netlifyTokenEnv || "NETLIFY_AUTH_TOKEN"],
|
|
77
|
+
siteId: process.env[config.build?.netlifySiteIdEnv || "NETLIFY_SITE_ID"],
|
|
78
|
+
defaultBranch: process.env.STAGING_BRANCH || config.content.publishBranch,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const app = express();
|
|
82
|
+
app.use(express.json({ limit: "10mb" }));
|
|
83
|
+
|
|
84
|
+
app.use((_req, res, next) => {
|
|
85
|
+
res.header("Access-Control-Allow-Origin", "*");
|
|
86
|
+
res.header("Access-Control-Allow-Methods", "GET,PUT,POST,DELETE,OPTIONS");
|
|
87
|
+
res.header("Access-Control-Allow-Headers", "Content-Type,Authorization");
|
|
88
|
+
if (_req.method === "OPTIONS") return res.sendStatus(204);
|
|
89
|
+
next();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// GitHub OAuth routes (unauthenticated — must be mounted before the auth guard)
|
|
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
|
+
}
|
|
97
|
+
|
|
98
|
+
if (auth.configured) {
|
|
99
|
+
app.use(
|
|
100
|
+
auth.middlewareWith(
|
|
101
|
+
(req) =>
|
|
102
|
+
req.path.startsWith(`${localMedia.urlPrefix}/`) ||
|
|
103
|
+
req.path === "/api/config" ||
|
|
104
|
+
req.path.startsWith("/admin/oauth/"),
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
} else {
|
|
108
|
+
console.warn(`WARNING: ${config.auth.passEnv} not set — running without authentication`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const TEMPLATES_DIR = path.join(rootDir, ".cms-templates");
|
|
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
|
+
});
|
|
236
|
+
|
|
237
|
+
app.get(`${localMedia.urlPrefix}/{*filepath}`, (req, res) => {
|
|
238
|
+
const sub = Array.isArray(req.params.filepath)
|
|
239
|
+
? req.params.filepath.join("/")
|
|
240
|
+
: req.params.filepath;
|
|
241
|
+
const full = localMedia.resolveLocalPath(sub);
|
|
242
|
+
if (!full) return res.status(404).send("Not found");
|
|
243
|
+
res.sendFile(full);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
function handleMediaError(res, err) {
|
|
247
|
+
const status = err.upstreamStatus || 500;
|
|
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
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
app.get("/api/deploy/status", async (req, res) => {
|
|
301
|
+
if (!build.configured) return res.json({ configured: false });
|
|
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
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
return { app, adapters: { content, localMedia, cdnMedia, auth, build } };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Start a background scheduler that auto-publishes entries whose
|
|
322
|
+
* `meta.publishAt` timestamp has passed. Runs every 60 seconds.
|
|
323
|
+
* Returns a stop function.
|
|
324
|
+
*/
|
|
325
|
+
export function startScheduler(content, intervalMs = 60_000) {
|
|
326
|
+
async function run() {
|
|
327
|
+
try {
|
|
328
|
+
const due = await content.listScheduled();
|
|
329
|
+
if (!due.length) return;
|
|
330
|
+
for (const { collection, file, data } of due) {
|
|
331
|
+
delete data.meta.publishAt;
|
|
332
|
+
data.meta.draft = false;
|
|
333
|
+
await content.writePage(collection, file, data);
|
|
334
|
+
console.log(`[scheduler] Auto-published ${collection}/${file}`);
|
|
335
|
+
}
|
|
336
|
+
const result = await content.publish();
|
|
337
|
+
console.log("[scheduler] " + result.message);
|
|
338
|
+
} catch (err) {
|
|
339
|
+
console.error("[scheduler] Error:", err.message);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const timer = setInterval(run, intervalMs);
|
|
343
|
+
return () => clearInterval(timer);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Mount the admin UI under /admin. Three modes:
|
|
348
|
+
* { mode: "static", dir } serve a prebuilt SPA bundle
|
|
349
|
+
* { mode: "vite-dev", root, base="/admin/" } use Vite middleware (HMR)
|
|
350
|
+
* { mode: "auto", distDir, sourceDir } vite-dev when NODE_ENV !== "production"
|
|
351
|
+
*/
|
|
352
|
+
export async function mountAdminUi(app, adminUi) {
|
|
353
|
+
if (!adminUi) return;
|
|
354
|
+
|
|
355
|
+
const resolved =
|
|
356
|
+
adminUi.mode === "auto"
|
|
357
|
+
? process.env.NODE_ENV === "production"
|
|
358
|
+
? { mode: "static", dir: adminUi.distDir }
|
|
359
|
+
: { mode: "vite-dev", root: adminUi.sourceDir, base: adminUi.base || "/admin/" }
|
|
360
|
+
: adminUi;
|
|
361
|
+
|
|
362
|
+
if (resolved.mode === "static") {
|
|
363
|
+
if (!fs.existsSync(path.join(resolved.dir, "index.html"))) {
|
|
364
|
+
throw new Error(
|
|
365
|
+
`admin-ui not built at ${resolved.dir}. Run the admin-ui build before starting the server in production.`,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
app.use("/admin", express.static(resolved.dir));
|
|
369
|
+
app.get("/admin", (_req, res) => {
|
|
370
|
+
res.sendFile(path.join(resolved.dir, "index.html"));
|
|
371
|
+
});
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (resolved.mode === "vite-dev") {
|
|
376
|
+
const { createServer: createViteServer } = await import("vite");
|
|
377
|
+
const vite = await createViteServer({
|
|
378
|
+
root: resolved.root,
|
|
379
|
+
base: resolved.base || "/admin/",
|
|
380
|
+
server: { middlewareMode: true },
|
|
381
|
+
appType: "spa",
|
|
382
|
+
});
|
|
383
|
+
app.use("/admin", vite.middlewares);
|
|
384
|
+
console.log("Admin UI: Vite dev middleware (HMR enabled)");
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
throw new Error(`Unknown adminUi mode: ${resolved.mode}`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Convenience: build the app, mount the admin UI, listen on a port.
|
|
393
|
+
* Returns { server, app, adapters }.
|
|
394
|
+
*/
|
|
395
|
+
export async function startCmsServer(opts) {
|
|
396
|
+
const { app, adapters } = createCmsServer(opts);
|
|
397
|
+
|
|
398
|
+
// Auto-detect admin UI dist when not explicitly configured.
|
|
399
|
+
const adminUi = opts.adminUi ?? (() => {
|
|
400
|
+
const dir = resolveAdminUiDir();
|
|
401
|
+
if (!dir) {
|
|
402
|
+
console.warn("admin-ui dist not found — run `npm run build:admin-ui` first, or pass adminUi option explicitly.");
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
return { mode: "static", dir };
|
|
406
|
+
})();
|
|
407
|
+
|
|
408
|
+
await mountAdminUi(app, adminUi);
|
|
409
|
+
const stopScheduler = startScheduler(adapters.content);
|
|
410
|
+
const port = opts.port || process.env.ADMIN_PORT || 4000;
|
|
411
|
+
return await new Promise((resolve) => {
|
|
412
|
+
const server = app.listen(port, () => {
|
|
413
|
+
console.log(`Admin server running at http://localhost:${port}/admin`);
|
|
414
|
+
resolve({ server, app, adapters, stopScheduler });
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
}
|