@stelstone/server 0.26.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 +275 -0
- package/bin/stelstone.mjs +181 -0
- package/package.json +53 -0
- package/src/adapters/_shared.mjs +401 -0
- package/src/adapters/basic-auth.mjs +102 -0
- package/src/adapters/build-netlify.mjs +60 -0
- package/src/adapters/cdn-proxy-media.mjs +79 -0
- package/src/adapters/cloudflare-access.mjs +144 -0
- package/src/adapters/fs-json-content.mjs +302 -0
- package/src/adapters/fs-templates.mjs +57 -0
- package/src/adapters/github-api.mjs +100 -0
- package/src/adapters/github-content.mjs +577 -0
- package/src/adapters/github-oauth.mjs +153 -0
- package/src/adapters/github-templates.mjs +100 -0
- package/src/adapters/index.mjs +12 -0
- package/src/adapters/local-assets-media.mjs +68 -0
- package/src/adapters/media-url.mjs +133 -0
- package/src/adapters/resend-mail.mjs +41 -0
- package/src/adapters/types.mjs +104 -0
- package/src/admin-ui-path.mjs +77 -0
- package/src/core/adapter-options.mjs +167 -0
- package/src/core/config-schema.mjs +408 -0
- package/src/core/forms.mjs +99 -0
- package/src/core/handler.mjs +209 -0
- package/src/core/node-adapter.mjs +99 -0
- package/src/core/static-files.mjs +115 -0
- package/src/default-public-config.mjs +39 -0
- package/src/index.mjs +22 -0
- package/src/routes.mjs +737 -0
- package/src/server.mjs +325 -0
- package/src/version.mjs +8 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers used across content and template adapters.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Strips all characters outside [a-zA-Z0-9._-].
|
|
7
|
+
* Used to sanitize path segments coming from config (collection names, etc.).
|
|
8
|
+
*/
|
|
9
|
+
export function sanitize(p) {
|
|
10
|
+
return String(p).replace(/[^a-zA-Z0-9._-]/g, "");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Validates a filename (e.g. "en-hello.json") from a route param.
|
|
15
|
+
* Returns the input unchanged if it is safe, or null if it should be rejected.
|
|
16
|
+
*
|
|
17
|
+
* Rules:
|
|
18
|
+
* - Must match /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/
|
|
19
|
+
* - Must not be "." or ".." or consist entirely of dots
|
|
20
|
+
*/
|
|
21
|
+
export function safeFileName(name) {
|
|
22
|
+
if (typeof name !== "string") return null;
|
|
23
|
+
if (name === "." || name === ".." || /^\.+$/.test(name)) return null;
|
|
24
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name)) return null;
|
|
25
|
+
return name;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* True when a filename is a content entry (and not a generated manifest such
|
|
30
|
+
* as `_index.json`). Manifest/index files are list projections, not entries,
|
|
31
|
+
* so every directory-scan that enumerates entries must exclude them.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} file
|
|
34
|
+
* @returns {boolean}
|
|
35
|
+
*/
|
|
36
|
+
export function isEntryFile(file) {
|
|
37
|
+
return typeof file === "string" && file.endsWith(".json") && !file.startsWith("_");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Sorts a pages array in-place and returns it.
|
|
42
|
+
*
|
|
43
|
+
* When sortConfig is provided, sorts by `data.meta?.[sortConfig.field]`.
|
|
44
|
+
* Missing values are always placed at the end, regardless of direction.
|
|
45
|
+
* When sortConfig is null/undefined, sorts by title (localeCompare).
|
|
46
|
+
*
|
|
47
|
+
* @param {Array} pages Each element must have `.meta` and `.title`.
|
|
48
|
+
* @param {Object|null} sortConfig { field: string, direction: "asc"|"desc" }
|
|
49
|
+
*/
|
|
50
|
+
export function sortPages(pages, sortConfig) {
|
|
51
|
+
if (sortConfig) {
|
|
52
|
+
const dir = sortConfig.direction === "desc" ? -1 : 1;
|
|
53
|
+
pages.sort((a, b) => {
|
|
54
|
+
const av = a[sortConfig.field] ?? a.meta?.[sortConfig.field];
|
|
55
|
+
const bv = b[sortConfig.field] ?? b.meta?.[sortConfig.field];
|
|
56
|
+
const aMissing = av === undefined || av === null;
|
|
57
|
+
const bMissing = bv === undefined || bv === null;
|
|
58
|
+
if (aMissing && bMissing) return 0;
|
|
59
|
+
if (aMissing) return 1; // missing always last
|
|
60
|
+
if (bMissing) return -1; // missing always last
|
|
61
|
+
if (av < bv) return -1 * dir;
|
|
62
|
+
if (av > bv) return 1 * dir;
|
|
63
|
+
return 0;
|
|
64
|
+
});
|
|
65
|
+
} else {
|
|
66
|
+
pages.sort((a, b) => a.title.localeCompare(b.title));
|
|
67
|
+
}
|
|
68
|
+
return pages;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Recursively regenerates block IDs.
|
|
73
|
+
* Children columns are arrays of block arrays (columns), so we recurse into each.
|
|
74
|
+
*/
|
|
75
|
+
export function reid(blocks) {
|
|
76
|
+
if (!Array.isArray(blocks)) return blocks;
|
|
77
|
+
return blocks.map((b) => {
|
|
78
|
+
const next = { ...b, id: "b-" + Math.random().toString(36).slice(2, 9) };
|
|
79
|
+
if (Array.isArray(b.children)) {
|
|
80
|
+
next.children = b.children.map((col) => reid(col));
|
|
81
|
+
}
|
|
82
|
+
return next;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Builds a duplicate page payload from a source page object.
|
|
88
|
+
* Returns { data, fileName }.
|
|
89
|
+
*/
|
|
90
|
+
export function buildDuplicateData(src) {
|
|
91
|
+
const lang = src.lang || "en";
|
|
92
|
+
const baseSlug = (src.slug || "page").replace(/-copy(-\d+)?$/, "");
|
|
93
|
+
const newSlug = `${baseSlug}-copy-${Math.floor(Date.now() / 1000)}`;
|
|
94
|
+
const data = {
|
|
95
|
+
...src,
|
|
96
|
+
id: `${lang}/${newSlug}`,
|
|
97
|
+
slug: newSlug,
|
|
98
|
+
meta: { ...(src.meta || {}), title: `${src.meta?.title || newSlug} (Copy)` },
|
|
99
|
+
blocks: reid(src.blocks || []),
|
|
100
|
+
};
|
|
101
|
+
const fileName = `${sanitize(lang)}-${sanitize(newSlug)}.json`;
|
|
102
|
+
return { data, fileName };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Scans all collections in `adapter` and returns entries whose
|
|
107
|
+
* `meta.publishAt` is in the past.
|
|
108
|
+
*
|
|
109
|
+
* @param {import('./types.mjs').ContentAdapter} adapter
|
|
110
|
+
* @returns {Promise<Array<{collection: string, file: string, data: object}>>}
|
|
111
|
+
*/
|
|
112
|
+
export async function listScheduledDue(adapter) {
|
|
113
|
+
const collections = await adapter.listCollections();
|
|
114
|
+
const now = new Date();
|
|
115
|
+
const due = [];
|
|
116
|
+
for (const { name } of collections) {
|
|
117
|
+
const pages = await adapter.listPages(name);
|
|
118
|
+
for (const page of pages || []) {
|
|
119
|
+
if (page.meta?.publishAt && new Date(page.meta.publishAt) <= now) {
|
|
120
|
+
const data = await adapter.readPage(name, page.file);
|
|
121
|
+
if (data?.meta?.publishAt) due.push({ collection: name, file: page.file, data });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return due;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Top-level keys read directly from the page object; all others come from page.meta. */
|
|
129
|
+
const TOP_LEVEL_KEYS = new Set(["id", "slug", "lang", "collection", "title", "file"]);
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Resolves a field value from a page object using the admin-UI field resolution rule.
|
|
133
|
+
* Top-level keys (id, slug, lang, collection, title, file) are read from the page directly;
|
|
134
|
+
* every other key is read from page.meta.
|
|
135
|
+
*
|
|
136
|
+
* @param {object} page
|
|
137
|
+
* @param {string} key
|
|
138
|
+
* @returns {string}
|
|
139
|
+
*/
|
|
140
|
+
export function pageFieldValue(page, key) {
|
|
141
|
+
if (TOP_LEVEL_KEYS.has(key)) return page[key] ?? "";
|
|
142
|
+
return page.meta?.[key] ?? "";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Meta keys the server and admin UI always need on a list entry regardless of
|
|
147
|
+
* a collection's `listFields`, because core workflow logic reads them:
|
|
148
|
+
* - `publishAt` — scheduled-publish detection (listScheduledDue) + status badge
|
|
149
|
+
* - `draft` — publish/draft/scheduled status badge
|
|
150
|
+
* These must never be dropped from the list index.
|
|
151
|
+
*/
|
|
152
|
+
export const SYSTEM_LIST_META_KEYS = ["draft", "publishAt"];
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Computes the set of `meta` keys that a list index must store for a
|
|
156
|
+
* collection. This is the union of the keys the list view actually consumes —
|
|
157
|
+
* `listFields`, `filters`, and sort keys — plus the always-required
|
|
158
|
+
* SYSTEM_LIST_META_KEYS. Top-level keys (id/slug/lang/collection/title/file)
|
|
159
|
+
* are excluded because they are stored at the top level of each entry, not
|
|
160
|
+
* under `meta`.
|
|
161
|
+
*
|
|
162
|
+
* Driving the index off config (instead of dumping the whole `meta` object)
|
|
163
|
+
* keeps manifests small and guarantees that every searchable/filterable/
|
|
164
|
+
* sortable field is present — so the admin list never shows a configured
|
|
165
|
+
* column or filter as silently empty.
|
|
166
|
+
*
|
|
167
|
+
* @param {object} [colConfig] A single collection's config object.
|
|
168
|
+
* @returns {string[]} Meta keys to project into the list entry.
|
|
169
|
+
*/
|
|
170
|
+
export function listMetaKeys(colConfig) {
|
|
171
|
+
const keys = new Set(SYSTEM_LIST_META_KEYS);
|
|
172
|
+
const addFields = (arr) => {
|
|
173
|
+
for (const f of arr || []) {
|
|
174
|
+
if (f && f.key) keys.add(f.key);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
addFields(colConfig?.listFields);
|
|
178
|
+
addFields(colConfig?.filters);
|
|
179
|
+
// Relation fields exist to be queried — "which entries point at this one" is
|
|
180
|
+
// the whole reason to declare one. Leaving them out of the manifest meant a
|
|
181
|
+
// reverse lookup had to open every entry body instead of reading the index.
|
|
182
|
+
for (const key of Object.keys(relationFields(colConfig))) keys.add(key);
|
|
183
|
+
|
|
184
|
+
// `sort` appears in two shapes in the wild. The canonical one — and the only
|
|
185
|
+
// one the API actually sorts by (see routes.mjs) — is a spec:
|
|
186
|
+
// `{ field, direction }`. An array of `{ key }` also exists. This function
|
|
187
|
+
// only needs the key names, so it accepts both; iterating the object form as
|
|
188
|
+
// an array used to throw and take `stelstone build-index` down with it.
|
|
189
|
+
if (Array.isArray(colConfig?.sort)) {
|
|
190
|
+
addFields(colConfig.sort);
|
|
191
|
+
} else {
|
|
192
|
+
const sortKey = colConfig?.sort?.field ?? colConfig?.sort?.key;
|
|
193
|
+
if (sortKey) keys.add(sortKey);
|
|
194
|
+
}
|
|
195
|
+
if (colConfig?.defaultSort?.key) keys.add(colConfig.defaultSort.key);
|
|
196
|
+
for (const k of TOP_LEVEL_KEYS) keys.delete(k);
|
|
197
|
+
return [...keys];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Builds the list-index record for a single page entry. Top-level
|
|
202
|
+
* id/slug/lang/collection/title/file are always stored; `meta` is projected
|
|
203
|
+
* down to only the keys returned by {@link listMetaKeys} for the collection.
|
|
204
|
+
*
|
|
205
|
+
* This is the single source of truth for list-entry shape, shared by every
|
|
206
|
+
* code path that produces one (fs listing, GitHub index read/write/bootstrap,
|
|
207
|
+
* and the `stelstone build-index` CLI) so they cannot diverge.
|
|
208
|
+
*
|
|
209
|
+
* Relation fields (a metaField with a `collection` target, e.g. a `combobox`)
|
|
210
|
+
* store a slug in the source data. When a `lookups` table for the target
|
|
211
|
+
* collection is supplied, the stored value is resolved to the referenced
|
|
212
|
+
* entry's display name so the admin list shows names instead of raw slugs.
|
|
213
|
+
* Resolution is best-effort: an unknown slug, or a missing lookup table, keeps
|
|
214
|
+
* the original slug. Producers that cannot cheaply load other collections
|
|
215
|
+
* (e.g. the GitHub edge write-path) simply omit `lookups` and store slugs.
|
|
216
|
+
*
|
|
217
|
+
* @param {object} [colConfig] The collection's config (for listFields etc.).
|
|
218
|
+
* @param {string} collection Collection name (fallback for entry.collection).
|
|
219
|
+
* @param {string} file Source file name.
|
|
220
|
+
* @param {object} data Full page object (has id/slug/lang/meta/...).
|
|
221
|
+
* @param {Object<string, Record<string,string>|Map<string,string>>} [lookups]
|
|
222
|
+
* Per-collection slug→displayName tables, keyed by target collection name.
|
|
223
|
+
* @returns {object} The projected list entry.
|
|
224
|
+
*/
|
|
225
|
+
export function buildListEntry(colConfig, collection, file, data, lookups = {}) {
|
|
226
|
+
const fullMeta = data.meta || {};
|
|
227
|
+
const meta = {};
|
|
228
|
+
for (const key of listMetaKeys(colConfig)) {
|
|
229
|
+
if (fullMeta[key] !== undefined) meta[key] = fullMeta[key];
|
|
230
|
+
}
|
|
231
|
+
resolveRelationLabels(meta, colConfig, lookups);
|
|
232
|
+
return {
|
|
233
|
+
id: data.id,
|
|
234
|
+
slug: data.slug,
|
|
235
|
+
lang: data.lang,
|
|
236
|
+
collection: data.collection ?? collection,
|
|
237
|
+
title: fullMeta.title || fullMeta.name || file,
|
|
238
|
+
file,
|
|
239
|
+
meta,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Separates a collection name from a slug in a qualified relation value. */
|
|
244
|
+
export const RELATION_SEPARATOR = ":";
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Splits `"blog:en/my-post"` into its parts. Slugs may contain `/` but never
|
|
248
|
+
* `:`, so the first separator is the boundary. Returns null for an unqualified
|
|
249
|
+
* value.
|
|
250
|
+
*/
|
|
251
|
+
export function parseRelationValue(value) {
|
|
252
|
+
if (typeof value !== "string") return null;
|
|
253
|
+
const at = value.indexOf(RELATION_SEPARATOR);
|
|
254
|
+
if (at <= 0) return null;
|
|
255
|
+
return { collection: value.slice(0, at), slug: value.slice(at + 1) };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Maps each relation field key of a collection to its target collections and
|
|
260
|
+
* cardinality. A metaField is a relation when it declares a `collection`
|
|
261
|
+
* target: `type: "combobox", collection: "authors"`, or several targets,
|
|
262
|
+
* `collection: ["reference", "blog"]`.
|
|
263
|
+
*
|
|
264
|
+
* With more than one target a bare slug no longer identifies an entry — the
|
|
265
|
+
* same slug can exist in two collections — so those values are stored
|
|
266
|
+
* qualified as `"collection:slug"`. Single-target fields keep storing a bare
|
|
267
|
+
* slug, so existing content and configs are unaffected.
|
|
268
|
+
*
|
|
269
|
+
* @param {object} [colConfig]
|
|
270
|
+
* @returns {Record<string, { collections: string[], multiple: boolean, qualified: boolean }>}
|
|
271
|
+
*/
|
|
272
|
+
export function relationFields(colConfig) {
|
|
273
|
+
const map = {};
|
|
274
|
+
for (const f of colConfig?.metaFields || []) {
|
|
275
|
+
if (!f || !f.key) continue;
|
|
276
|
+
const targets = typeof f.collection === "string"
|
|
277
|
+
? [f.collection]
|
|
278
|
+
: Array.isArray(f.collection)
|
|
279
|
+
? f.collection.filter((c) => typeof c === "string" && c)
|
|
280
|
+
: null;
|
|
281
|
+
if (!targets || targets.length === 0) continue;
|
|
282
|
+
map[f.key] = {
|
|
283
|
+
collections: targets,
|
|
284
|
+
multiple: !!f.multiple,
|
|
285
|
+
qualified: targets.length > 1,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
return map;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* In-place resolution of relation slugs in a projected `meta` object to the
|
|
293
|
+
* referenced entries' display names, using the supplied `lookups` tables.
|
|
294
|
+
* Handles both single values and arrays (multiple-select). Best-effort: leaves
|
|
295
|
+
* the slug untouched when no resolution is available.
|
|
296
|
+
*
|
|
297
|
+
* @param {object} meta The projected meta object (mutated).
|
|
298
|
+
* @param {object} [colConfig]
|
|
299
|
+
* @param {object} [lookups] Per-collection slug→displayName tables.
|
|
300
|
+
*/
|
|
301
|
+
export function resolveRelationLabels(meta, colConfig, lookups = {}) {
|
|
302
|
+
const relations = relationFields(colConfig);
|
|
303
|
+
for (const key of Object.keys(meta)) {
|
|
304
|
+
const rel = relations[key];
|
|
305
|
+
if (!rel) continue;
|
|
306
|
+
|
|
307
|
+
const lookIn = (target, slug) => {
|
|
308
|
+
const table = lookups[target];
|
|
309
|
+
if (!table) return null;
|
|
310
|
+
const name = table instanceof Map ? table.get(slug) : table[slug];
|
|
311
|
+
return name != null && name !== "" ? name : null;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const resolveOne = (raw) => {
|
|
315
|
+
// A qualified value names its own collection; an unqualified one belongs
|
|
316
|
+
// to the field's single target.
|
|
317
|
+
const parsed = rel.qualified ? parseRelationValue(raw) : null;
|
|
318
|
+
if (parsed) return lookIn(parsed.collection, parsed.slug) ?? raw;
|
|
319
|
+
return lookIn(rel.collections[0], raw) ?? raw;
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const value = meta[key];
|
|
323
|
+
meta[key] = Array.isArray(value) ? value.map(resolveOne) : resolveOne(value);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Applies pagination, search, and filtering to a pre-sorted pages array.
|
|
329
|
+
* Does not mutate the input array.
|
|
330
|
+
*
|
|
331
|
+
* @param {object[]} pages Pre-sorted array of page objects.
|
|
332
|
+
* @param {object} opts
|
|
333
|
+
* @param {number} [opts.page=1] Current page (1-based).
|
|
334
|
+
* @param {number|"all"} [opts.perPage=20] Items per page, or "all" / 0 for no pagination.
|
|
335
|
+
* @param {string} [opts.search=""] Substring search term.
|
|
336
|
+
* @param {string[]} [opts.searchFields=[]] Keys to search across.
|
|
337
|
+
* @param {string[]} [opts.filterKeys=[]] Keys for which to compute facet values.
|
|
338
|
+
* @param {object} [opts.filters={}] Active filters: { key: exactValue }.
|
|
339
|
+
* @returns {{ items: object[], total: number, facets: Record<string, string[]> }}
|
|
340
|
+
*/
|
|
341
|
+
export function queryPages(pages, {
|
|
342
|
+
page = 1,
|
|
343
|
+
perPage = 20,
|
|
344
|
+
search = "",
|
|
345
|
+
searchFields = [],
|
|
346
|
+
filterKeys = [],
|
|
347
|
+
filters = {},
|
|
348
|
+
} = {}) {
|
|
349
|
+
// 1. Facets — computed over the full pages array
|
|
350
|
+
/** @type {Record<string, string[]>} */
|
|
351
|
+
const facets = {};
|
|
352
|
+
for (const key of filterKeys) {
|
|
353
|
+
const seen = new Set();
|
|
354
|
+
for (const p of pages) {
|
|
355
|
+
const v = String(pageFieldValue(p, key));
|
|
356
|
+
if (v !== "") seen.add(v);
|
|
357
|
+
}
|
|
358
|
+
facets[key] = [...seen].sort();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// 2. Filter
|
|
362
|
+
const activeFilters = Object.entries(filters).filter(([, v]) => v);
|
|
363
|
+
const filtered = activeFilters.length === 0
|
|
364
|
+
? pages
|
|
365
|
+
: pages.filter((p) =>
|
|
366
|
+
activeFilters.every(([k, v]) => String(pageFieldValue(p, k)) === v)
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
// 3. Search
|
|
370
|
+
const term = search ? search.toLowerCase() : "";
|
|
371
|
+
const searched = term
|
|
372
|
+
? filtered.filter((p) =>
|
|
373
|
+
searchFields.some((k) =>
|
|
374
|
+
String(pageFieldValue(p, k)).toLowerCase().includes(term)
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
: filtered;
|
|
378
|
+
|
|
379
|
+
// 4. Total
|
|
380
|
+
const total = searched.length;
|
|
381
|
+
|
|
382
|
+
// 5. Paginate
|
|
383
|
+
const perPageNum = perPage === "all" ? 0 : Number(perPage);
|
|
384
|
+
const items =
|
|
385
|
+
perPage === "all" || perPageNum === 0
|
|
386
|
+
? searched.slice()
|
|
387
|
+
: searched.slice((page - 1) * perPageNum, page * perPageNum);
|
|
388
|
+
|
|
389
|
+
return { items, total, facets };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Produces a commit message string.
|
|
394
|
+
*
|
|
395
|
+
* @param {((ts: string) => string) | undefined} commitMessage
|
|
396
|
+
* @param {string} [fallback="Content updated"]
|
|
397
|
+
*/
|
|
398
|
+
export function commitMsg(commitMessage, fallback = "Content updated") {
|
|
399
|
+
const ts = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
400
|
+
return commitMessage ? commitMessage(ts) : `${fallback} ${ts}`;
|
|
401
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HTTP Basic auth + HMAC-SHA256 JWT for media tokens.
|
|
5
|
+
*
|
|
6
|
+
* @param {Object} opts
|
|
7
|
+
* @param {string} opts.user
|
|
8
|
+
* @param {string|undefined} opts.pass
|
|
9
|
+
* @param {string|undefined} opts.jwtSecret
|
|
10
|
+
* @param {number} opts.jwtTtl
|
|
11
|
+
* @param {string} [opts.realm="Admin"]
|
|
12
|
+
* @returns {import('./types.mjs').AuthAdapter}
|
|
13
|
+
*/
|
|
14
|
+
function safeEq(a, b) {
|
|
15
|
+
const bufA = Buffer.from(a || "", "utf8");
|
|
16
|
+
const bufB = Buffer.from(b || "", "utf8");
|
|
17
|
+
if (bufA.length !== bufB.length) return false;
|
|
18
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createBasicAuth({
|
|
22
|
+
user,
|
|
23
|
+
pass,
|
|
24
|
+
users,
|
|
25
|
+
jwtSecret,
|
|
26
|
+
jwtTtl,
|
|
27
|
+
realm = "Admin",
|
|
28
|
+
}) {
|
|
29
|
+
// Normalise: prefer `users` array, fall back to single user/pass pair.
|
|
30
|
+
// Each entry may use `pass` (plaintext) or `passEnv` (env var name).
|
|
31
|
+
// Passwords arrive already resolved (see core/adapter-options.mjs). An
|
|
32
|
+
// adapter that reads process.env itself cannot run in a Worker, which is
|
|
33
|
+
// what forced the Worker to re-resolve this list by hand.
|
|
34
|
+
const userList = (users?.length
|
|
35
|
+
? users
|
|
36
|
+
: pass ? [{ user: user || "admin", pass, role: "admin" }] : []
|
|
37
|
+
)
|
|
38
|
+
// A user whose password resolves to empty/undefined must be DISABLED,
|
|
39
|
+
// not matchable — otherwise an empty submitted password would log in.
|
|
40
|
+
// Reporting is left to config validation (core/config-schema.mjs), which
|
|
41
|
+
// names the config path and the environment variable; warning here too
|
|
42
|
+
// just prints the same problem twice.
|
|
43
|
+
.filter((entry) => Boolean(entry.pass));
|
|
44
|
+
|
|
45
|
+
const configured = userList.length > 0;
|
|
46
|
+
|
|
47
|
+
function findUser(u, p) {
|
|
48
|
+
return userList.find(
|
|
49
|
+
(entry) => safeEq(u, entry.user) && safeEq(p, entry.pass),
|
|
50
|
+
) ?? null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function issueMediaToken(tenantId) {
|
|
54
|
+
if (!jwtSecret) throw new Error("JWT_SECRET not set");
|
|
55
|
+
const header = Buffer.from(
|
|
56
|
+
JSON.stringify({ alg: "HS256", typ: "JWT" }),
|
|
57
|
+
).toString("base64url");
|
|
58
|
+
const payload = Buffer.from(
|
|
59
|
+
JSON.stringify({
|
|
60
|
+
sub: user,
|
|
61
|
+
tenant_id: tenantId,
|
|
62
|
+
iat: Math.floor(Date.now() / 1000),
|
|
63
|
+
exp: Math.floor(Date.now() / 1000) + jwtTtl,
|
|
64
|
+
}),
|
|
65
|
+
).toString("base64url");
|
|
66
|
+
const sig = crypto
|
|
67
|
+
.createHmac("sha256", jwtSecret)
|
|
68
|
+
.update(`${header}.${payload}`)
|
|
69
|
+
.digest("base64url");
|
|
70
|
+
return `${header}.${payload}.${sig}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Resolve the caller from the request.
|
|
75
|
+
*
|
|
76
|
+
* The CMS never sends WWW-Authenticate on failure: credentials were supplied
|
|
77
|
+
* and rejected, and the header would pop the browser's native Basic Auth
|
|
78
|
+
* dialog over the app's own error UI.
|
|
79
|
+
*
|
|
80
|
+
* @param {Request} request
|
|
81
|
+
* @returns {Promise<object|null>}
|
|
82
|
+
*/
|
|
83
|
+
async function verify(request) {
|
|
84
|
+
const authHeader = request.headers.get("authorization");
|
|
85
|
+
if (!authHeader?.startsWith("Basic ")) return null;
|
|
86
|
+
const decoded = Buffer.from(authHeader.slice(6), "base64").toString();
|
|
87
|
+
const colon = decoded.indexOf(":");
|
|
88
|
+
const u = decoded.slice(0, colon);
|
|
89
|
+
const p = decoded.slice(colon + 1);
|
|
90
|
+
const entry = findUser(u, p);
|
|
91
|
+
if (!entry) return null;
|
|
92
|
+
return { login: entry.user, name: entry.name || entry.user, role: entry.role || "editor" };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
configured,
|
|
97
|
+
/** @param {string} capability */
|
|
98
|
+
supports: (capability) => capability === "mediaToken",
|
|
99
|
+
issueMediaToken,
|
|
100
|
+
verify,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Netlify deploys API integration.
|
|
3
|
+
*
|
|
4
|
+
* @param {Object} opts
|
|
5
|
+
* @param {string|undefined} opts.token
|
|
6
|
+
* @param {string|undefined} opts.siteId
|
|
7
|
+
* @param {string} opts.defaultBranch
|
|
8
|
+
* @returns {import('./types.mjs').BuildAdapter}
|
|
9
|
+
*/
|
|
10
|
+
export function createNetlifyBuild({ token, siteId, defaultBranch }) {
|
|
11
|
+
const configured = Boolean(token && siteId);
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
configured,
|
|
15
|
+
|
|
16
|
+
async getDeployStatus({ branch, sha } = {}) {
|
|
17
|
+
if (!configured) return { configured: false };
|
|
18
|
+
const targetBranch = branch || defaultBranch;
|
|
19
|
+
const url = `https://api.netlify.com/api/v1/sites/${siteId}/deploys?per_page=20&branch=${encodeURIComponent(
|
|
20
|
+
targetBranch,
|
|
21
|
+
)}`;
|
|
22
|
+
const r = await fetch(url, {
|
|
23
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
24
|
+
});
|
|
25
|
+
if (!r.ok) {
|
|
26
|
+
const err = new Error(`Netlify API ${r.status}`);
|
|
27
|
+
err.upstreamStatus = r.status;
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
const deploys = await r.json();
|
|
31
|
+
let deploy = null;
|
|
32
|
+
if (sha) {
|
|
33
|
+
deploy = deploys.find(
|
|
34
|
+
(d) => d.commit_ref && d.commit_ref.startsWith(sha),
|
|
35
|
+
);
|
|
36
|
+
// SHA not found yet — Netlify hasn't picked up the commit, keep waiting
|
|
37
|
+
if (!deploy) return { configured: true, deploy: null };
|
|
38
|
+
}
|
|
39
|
+
if (!deploy) deploy = deploys[0] || null;
|
|
40
|
+
if (!deploy) return { configured: true, deploy: null };
|
|
41
|
+
return {
|
|
42
|
+
configured: true,
|
|
43
|
+
deploy: {
|
|
44
|
+
id: deploy.id,
|
|
45
|
+
state: deploy.state,
|
|
46
|
+
branch: deploy.branch,
|
|
47
|
+
commitRef: deploy.commit_ref,
|
|
48
|
+
deployUrl: deploy.deploy_ssl_url || deploy.deploy_url,
|
|
49
|
+
adminUrl: deploy.admin_url
|
|
50
|
+
? `${deploy.admin_url}/deploys/${deploy.id}`
|
|
51
|
+
: null,
|
|
52
|
+
createdAt: deploy.created_at,
|
|
53
|
+
updatedAt: deploy.updated_at,
|
|
54
|
+
errorMessage: deploy.error_message || null,
|
|
55
|
+
title: deploy.title || null,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { sanitize } from "./_shared.mjs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CDN-backed media adapter that proxies requests to a remote media
|
|
5
|
+
* service expecting Bearer-token auth (e.g. the natilon media-cdn lambda).
|
|
6
|
+
*
|
|
7
|
+
* The admin server calls these methods server-side, so the browser never
|
|
8
|
+
* needs to talk to the CDN directly — eliminating CORS friction and
|
|
9
|
+
* keeping the media JWT off the client.
|
|
10
|
+
*
|
|
11
|
+
* Backend contract (mirrors the lambda):
|
|
12
|
+
* GET / → { folders: string[] }
|
|
13
|
+
* GET /{folder}/ → { items, page, pages, total }
|
|
14
|
+
* POST /_upload → { key, ... }
|
|
15
|
+
*
|
|
16
|
+
* @param {Object} opts
|
|
17
|
+
* @param {string} opts.baseUrl e.g. "https://media.natilon.com"
|
|
18
|
+
* @param {() => string} opts.getToken Returns a fresh JWT each call.
|
|
19
|
+
* @returns {{
|
|
20
|
+
* listFolders: () => Promise<object>,
|
|
21
|
+
* listFolder: (folder: string, q?: {page?: number, perPage?: number, search?: string}) => Promise<object>,
|
|
22
|
+
* upload: (payload: object) => Promise<object>,
|
|
23
|
+
* }}
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export function createCdnProxyMedia({ baseUrl, getToken }) {
|
|
27
|
+
const ROOT = baseUrl.replace(/\/+$/, "");
|
|
28
|
+
|
|
29
|
+
async function call(path, opts = {}) {
|
|
30
|
+
const r = await fetch(ROOT + path, {
|
|
31
|
+
...opts,
|
|
32
|
+
headers: {
|
|
33
|
+
Authorization: `Bearer ${getToken()}`,
|
|
34
|
+
...(opts.body ? { "Content-Type": "application/json" } : {}),
|
|
35
|
+
...(opts.headers || {}),
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
const text = await r.text();
|
|
39
|
+
let body;
|
|
40
|
+
try {
|
|
41
|
+
body = text ? JSON.parse(text) : {};
|
|
42
|
+
} catch {
|
|
43
|
+
const err = new Error(`Media backend returned non-JSON (${r.status})`);
|
|
44
|
+
err.upstreamStatus = r.status;
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
if (!r.ok) {
|
|
48
|
+
const err = new Error(body?.error || `Media backend ${r.status}`);
|
|
49
|
+
err.upstreamStatus = r.status;
|
|
50
|
+
err.body = body;
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
return body;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
listFolders() {
|
|
58
|
+
// `/_list/` matches CloudFront's `*/` behavior so it routes to the
|
|
59
|
+
// lambda; bare `/` would hit the S3 default behavior and 403.
|
|
60
|
+
return call("/_list/");
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
listFolder(folder, { page = 1, perPage = 30, search = "" } = {}) {
|
|
64
|
+
const params = new URLSearchParams({
|
|
65
|
+
page: String(page),
|
|
66
|
+
per_page: String(perPage),
|
|
67
|
+
});
|
|
68
|
+
if (search) params.set("search", search);
|
|
69
|
+
return call(`/${sanitize(folder)}/?${params.toString()}`);
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
upload(payload) {
|
|
73
|
+
return call("/_upload", {
|
|
74
|
+
method: "POST",
|
|
75
|
+
body: JSON.stringify(payload),
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|