@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,153 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GitHub OAuth adapter — drop-in replacement for createBasicAuth.
|
|
5
|
+
*
|
|
6
|
+
* Config (cms.config.mjs):
|
|
7
|
+
* auth: {
|
|
8
|
+
* provider: "github-oauth",
|
|
9
|
+
* githubClientIdEnv: "GITHUB_CLIENT_ID",
|
|
10
|
+
* githubClientSecretEnv: "GITHUB_CLIENT_SECRET",
|
|
11
|
+
* allowedLogins: ["your-github-username"], // whitelist
|
|
12
|
+
* jwtSecretEnv: "JWT_SECRET",
|
|
13
|
+
* jwtTtl: 8 * 60 * 60,
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* Add to your .env:
|
|
17
|
+
* GITHUB_CLIENT_ID=Ov23li...
|
|
18
|
+
* GITHUB_CLIENT_SECRET=...
|
|
19
|
+
*
|
|
20
|
+
* Register callback URL in your GitHub OAuth App:
|
|
21
|
+
* http://localhost:4001/admin/oauth/callback (dev)
|
|
22
|
+
* https://admin.yoursite.com/admin/oauth/callback (prod)
|
|
23
|
+
*
|
|
24
|
+
* @param {Object} opts
|
|
25
|
+
* @param {string} opts.clientId
|
|
26
|
+
* @param {string} opts.clientSecret
|
|
27
|
+
* @param {string[]} opts.allowedLogins GitHub usernames allowed in
|
|
28
|
+
* @param {string} opts.jwtSecret
|
|
29
|
+
* @param {number} opts.jwtTtl Session lifetime in seconds
|
|
30
|
+
* @param {string} [opts.realm]
|
|
31
|
+
* @returns {import('./types.mjs').AuthAdapter & { oauthRoutes: { login, callback } }}
|
|
32
|
+
*/
|
|
33
|
+
export function createGitHubOAuth({
|
|
34
|
+
clientId,
|
|
35
|
+
clientSecret,
|
|
36
|
+
allowedLogins = [],
|
|
37
|
+
roles = {},
|
|
38
|
+
jwtSecret,
|
|
39
|
+
jwtTtl = 8 * 60 * 60,
|
|
40
|
+
defaultRole = "editor",
|
|
41
|
+
realm = "Admin",
|
|
42
|
+
}) {
|
|
43
|
+
// Least privilege: an allowed login without an explicit role entry gets
|
|
44
|
+
// `defaultRole` ("editor"), matching the basic-auth adapter. Grant admin
|
|
45
|
+
// via roles: { "login": "admin" } or roles: { "*": "admin" }.
|
|
46
|
+
function getRole(login) {
|
|
47
|
+
if (roles[login]) return roles[login];
|
|
48
|
+
if (roles["*"]) return roles["*"];
|
|
49
|
+
return defaultRole;
|
|
50
|
+
}
|
|
51
|
+
const configured = Boolean(clientId && clientSecret);
|
|
52
|
+
|
|
53
|
+
function sign(header, payload) {
|
|
54
|
+
return crypto
|
|
55
|
+
.createHmac("sha256", jwtSecret)
|
|
56
|
+
.update(`${header}.${payload}`)
|
|
57
|
+
.digest("base64url");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function issueToken(claims) {
|
|
61
|
+
if (!jwtSecret) throw new Error("JWT_SECRET not set");
|
|
62
|
+
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
|
|
63
|
+
const payload = Buffer.from(JSON.stringify({
|
|
64
|
+
iat: Math.floor(Date.now() / 1000),
|
|
65
|
+
exp: Math.floor(Date.now() / 1000) + jwtTtl,
|
|
66
|
+
...claims,
|
|
67
|
+
})).toString("base64url");
|
|
68
|
+
return `${header}.${payload}.${sign(header, payload)}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function verifyToken(tokenStr) {
|
|
72
|
+
if (!jwtSecret) return null;
|
|
73
|
+
const parts = (tokenStr || "").split(".");
|
|
74
|
+
if (parts.length !== 3) return null;
|
|
75
|
+
const [header, payload, sig] = parts;
|
|
76
|
+
|
|
77
|
+
// Decode and validate header
|
|
78
|
+
try {
|
|
79
|
+
const headerData = JSON.parse(Buffer.from(header, "base64url").toString());
|
|
80
|
+
if (headerData.alg !== "HS256" || headerData.typ !== "JWT") return null;
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Timing-safe signature comparison
|
|
86
|
+
const expectedSig = sign(header, payload);
|
|
87
|
+
const sigBuf = Buffer.from(sig, "base64url");
|
|
88
|
+
const expectedBuf = Buffer.from(expectedSig, "base64url");
|
|
89
|
+
if (sigBuf.length !== expectedBuf.length) return null;
|
|
90
|
+
try {
|
|
91
|
+
if (!crypto.timingSafeEqual(sigBuf, expectedBuf)) return null;
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const data = JSON.parse(Buffer.from(payload, "base64url").toString());
|
|
98
|
+
// A token without exp must be rejected — `undefined < now` is false,
|
|
99
|
+
// which would make it valid forever.
|
|
100
|
+
if (typeof data.exp !== "number" || data.exp < Math.floor(Date.now() / 1000)) return null;
|
|
101
|
+
return data;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @param {Request} request
|
|
109
|
+
* @returns {Promise<object|null>}
|
|
110
|
+
*/
|
|
111
|
+
async function verify(request) {
|
|
112
|
+
const authHeader = request.headers.get("authorization") || "";
|
|
113
|
+
if (!authHeader.startsWith("Bearer ")) return null;
|
|
114
|
+
const claims = verifyToken(authHeader.slice(7));
|
|
115
|
+
if (!claims || claims.type !== "session") return null;
|
|
116
|
+
return { login: claims.sub, role: getRole(claims.sub), name: claims.name };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function issueSessionToken(login, name) {
|
|
120
|
+
return issueToken({ sub: login, type: "session", name });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function issueOAuthState() {
|
|
124
|
+
if (!jwtSecret) throw new Error("JWT_SECRET not set");
|
|
125
|
+
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
|
|
126
|
+
const now = Math.floor(Date.now() / 1000);
|
|
127
|
+
const payload = Buffer.from(JSON.stringify({
|
|
128
|
+
type: "oauth-state",
|
|
129
|
+
nonce: crypto.randomBytes(16).toString("hex"),
|
|
130
|
+
iat: now,
|
|
131
|
+
exp: now + 600,
|
|
132
|
+
})).toString("base64url");
|
|
133
|
+
return `${header}.${payload}.${sign(header, payload)}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function verifyOAuthState(state) {
|
|
137
|
+
const claims = verifyToken(state);
|
|
138
|
+
return Boolean(claims && claims.type === "oauth-state");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
configured,
|
|
143
|
+
/** @param {string} capability */
|
|
144
|
+
supports: (capability) => ["mediaToken", "session", "oauth"].includes(capability),
|
|
145
|
+
issueMediaToken(tenantId) {
|
|
146
|
+
return issueToken({ sub: "media", tenant_id: tenantId, type: "media" });
|
|
147
|
+
},
|
|
148
|
+
verify,
|
|
149
|
+
issueSessionToken,
|
|
150
|
+
issueOAuthState,
|
|
151
|
+
verifyOAuthState,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createGitHubApi } from "./github-api.mjs";
|
|
2
|
+
import { sanitize, safeFileName, commitMsg } from "./_shared.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GitHub Contents API-backed block-template adapter.
|
|
6
|
+
*
|
|
7
|
+
* Templates are stored under `<templatesDir>/<slug>.json` on a configurable
|
|
8
|
+
* branch. Safe to run on any serverless runtime — no local filesystem.
|
|
9
|
+
*
|
|
10
|
+
* @param {Object} opts
|
|
11
|
+
* @param {string} opts.token GitHub PAT with repo scope
|
|
12
|
+
* @param {string} opts.owner
|
|
13
|
+
* @param {string} opts.repo
|
|
14
|
+
* @param {string} opts.branch
|
|
15
|
+
* @param {string} [opts.templatesDir=".cms-templates"]
|
|
16
|
+
* @param {(ts: string) => string} [opts.commitMessage]
|
|
17
|
+
* @returns {import('./types.mjs').TemplatesAdapter}
|
|
18
|
+
*/
|
|
19
|
+
export function createGitHubTemplates({
|
|
20
|
+
token,
|
|
21
|
+
owner,
|
|
22
|
+
repo,
|
|
23
|
+
branch,
|
|
24
|
+
templatesDir = ".cms-templates",
|
|
25
|
+
commitMessage,
|
|
26
|
+
}) {
|
|
27
|
+
const { apiGet, apiPut, apiDelete } = createGitHubApi({ token, owner, repo });
|
|
28
|
+
const shaCache = new Map();
|
|
29
|
+
|
|
30
|
+
function filePath(slug) {
|
|
31
|
+
return `${templatesDir}/${sanitize(slug)}.json`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function msg() {
|
|
35
|
+
return commitMsg(commitMessage, "Template updated");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
async list() {
|
|
40
|
+
const items = await apiGet(`/contents/${templatesDir}?ref=${branch}`);
|
|
41
|
+
if (!items || !Array.isArray(items)) return [];
|
|
42
|
+
const jsonFiles = items.filter((i) => i.type === "file" && i.name.endsWith(".json"));
|
|
43
|
+
return Promise.all(
|
|
44
|
+
jsonFiles.map(async (item) => {
|
|
45
|
+
shaCache.set(item.path, item.sha);
|
|
46
|
+
const r = await fetch(item.download_url, {
|
|
47
|
+
headers: { Authorization: `Bearer ${token}`, "User-Agent": "stelstone" },
|
|
48
|
+
});
|
|
49
|
+
if (!r.ok) return null;
|
|
50
|
+
const data = await r.json();
|
|
51
|
+
return {
|
|
52
|
+
name: data.name,
|
|
53
|
+
slug: item.name.replace(".json", ""),
|
|
54
|
+
blockCount: (data.blocks || []).length,
|
|
55
|
+
};
|
|
56
|
+
}),
|
|
57
|
+
).then((rs) => rs.filter(Boolean));
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
async get(slug) {
|
|
61
|
+
if (!safeFileName(slug)) return null;
|
|
62
|
+
const path = filePath(slug);
|
|
63
|
+
const data = await apiGet(`/contents/${path}?ref=${branch}`);
|
|
64
|
+
if (!data || Array.isArray(data)) return null;
|
|
65
|
+
shaCache.set(path, data.sha);
|
|
66
|
+
return JSON.parse(Buffer.from(data.content, "base64").toString("utf-8"));
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
async put(slug, data) {
|
|
70
|
+
if (!safeFileName(slug)) return null;
|
|
71
|
+
const path = filePath(slug);
|
|
72
|
+
let sha = shaCache.get(path);
|
|
73
|
+
if (!sha) {
|
|
74
|
+
const existing = await apiGet(`/contents/${path}?ref=${branch}`);
|
|
75
|
+
if (existing && !Array.isArray(existing)) sha = existing.sha;
|
|
76
|
+
}
|
|
77
|
+
const result = await apiPut(`/contents/${path}`, {
|
|
78
|
+
message: msg(),
|
|
79
|
+
content: Buffer.from(JSON.stringify(data, null, 2), "utf-8").toString("base64"),
|
|
80
|
+
branch,
|
|
81
|
+
...(sha ? { sha } : {}),
|
|
82
|
+
});
|
|
83
|
+
shaCache.set(path, result.content?.sha);
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
async delete(slug) {
|
|
87
|
+
if (!safeFileName(slug)) return null;
|
|
88
|
+
const path = filePath(slug);
|
|
89
|
+
let sha = shaCache.get(path);
|
|
90
|
+
if (!sha) {
|
|
91
|
+
const existing = await apiGet(`/contents/${path}?ref=${branch}`);
|
|
92
|
+
if (!existing || Array.isArray(existing)) return false;
|
|
93
|
+
sha = existing.sha;
|
|
94
|
+
}
|
|
95
|
+
await apiDelete(`/contents/${path}`, { message: msg(), sha, branch });
|
|
96
|
+
shaCache.delete(path);
|
|
97
|
+
return true;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { createFsJsonContent } from "./fs-json-content.mjs";
|
|
2
|
+
export { createFsTemplates } from "./fs-templates.mjs";
|
|
3
|
+
export { createGitHubContent } from "./github-content.mjs";
|
|
4
|
+
export { createGitHubTemplates } from "./github-templates.mjs";
|
|
5
|
+
export { createLocalAssetsMedia } from "./local-assets-media.mjs";
|
|
6
|
+
export { createCdnProxyMedia } from "./cdn-proxy-media.mjs";
|
|
7
|
+
export { createBasicAuth } from "./basic-auth.mjs";
|
|
8
|
+
export { createGitHubOAuth } from "./github-oauth.mjs";
|
|
9
|
+
export { createCloudflareAccess } from "./cloudflare-access.mjs";
|
|
10
|
+
export { createNetlifyBuild } from "./build-netlify.mjs";
|
|
11
|
+
export { createMediaUrl } from "./media-url.mjs";
|
|
12
|
+
export { createResendMail } from "./resend-mail.mjs";
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { sanitize } from "./_shared.mjs";
|
|
4
|
+
|
|
5
|
+
const IMAGE_RE = /\.(png|jpg|jpeg|svg|webp|gif)$/i;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Local-filesystem media adapter for the legacy file picker.
|
|
9
|
+
* Lists images grouped by top-level folder under `<rootDir>/<assetsDir>`.
|
|
10
|
+
*
|
|
11
|
+
* @param {Object} opts
|
|
12
|
+
* @param {string} opts.rootDir
|
|
13
|
+
* @param {string} opts.assetsDir Relative to rootDir, e.g. "src/assets".
|
|
14
|
+
* @returns {import('./types.mjs').MediaAdapter & {urlPrefix: string}}
|
|
15
|
+
*/
|
|
16
|
+
export function createLocalAssetsMedia({ rootDir, assetsDir }) {
|
|
17
|
+
const ROOT = path.join(rootDir, assetsDir);
|
|
18
|
+
const URL_PREFIX = `/${assetsDir.replace(/^\/+/, "")}`;
|
|
19
|
+
|
|
20
|
+
function listImages(dir, prefix) {
|
|
21
|
+
const out = [];
|
|
22
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
const full = path.join(dir, entry.name);
|
|
25
|
+
if (entry.isDirectory()) {
|
|
26
|
+
out.push(...listImages(full, `${prefix}/${entry.name}`));
|
|
27
|
+
} else if (IMAGE_RE.test(entry.name)) {
|
|
28
|
+
out.push(`${prefix}/${entry.name}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
async listGrouped() {
|
|
36
|
+
if (!fs.existsSync(ROOT)) return {};
|
|
37
|
+
const folders = fs
|
|
38
|
+
.readdirSync(ROOT)
|
|
39
|
+
.filter((d) => fs.statSync(path.join(ROOT, d)).isDirectory());
|
|
40
|
+
const out = {};
|
|
41
|
+
for (const folder of folders) {
|
|
42
|
+
const files = listImages(
|
|
43
|
+
path.join(ROOT, folder),
|
|
44
|
+
`${URL_PREFIX}/${folder}`,
|
|
45
|
+
);
|
|
46
|
+
if (files.length > 0) out[folder] = files;
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
async listFolder(folder) {
|
|
52
|
+
const dir = path.join(ROOT, sanitize(folder));
|
|
53
|
+
if (!fs.existsSync(dir)) return [];
|
|
54
|
+
return listImages(dir, `${URL_PREFIX}/${sanitize(folder)}`);
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
resolveLocalPath(relPath) {
|
|
58
|
+
const sub = String(relPath).replace(/^\/+/, "");
|
|
59
|
+
const full = path.resolve(ROOT, sub);
|
|
60
|
+
// Compare with a trailing separator — a bare startsWith(ROOT) would
|
|
61
|
+
// also accept sibling dirs like "<ROOT>-evil".
|
|
62
|
+
if (!full.startsWith(ROOT + path.sep) || !fs.existsSync(full)) return null;
|
|
63
|
+
return full;
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
urlPrefix: URL_PREFIX,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build CDN URLs and responsive <img> props for media stored behind a
|
|
3
|
+
* CloudFront/S3 (or compatible) origin with a path-based resize behavior.
|
|
4
|
+
*
|
|
5
|
+
* Pure factory — no environment, no fs. Configure once with the consumer's
|
|
6
|
+
* `cms.config.media` settings; everything else is derived.
|
|
7
|
+
*
|
|
8
|
+
* @typedef {Object} MediaUrlConfig
|
|
9
|
+
* @property {string} cdnBase e.g. "https://media.natilon.com"
|
|
10
|
+
* @property {string} [resizePrefix="/_r/"] path prefix for the resize behavior
|
|
11
|
+
* @property {number[]} [defaultWidths] fallback srcset widths
|
|
12
|
+
* @property {string} [tenantId] multi-tenant prefix, e.g. "natilon"
|
|
13
|
+
*
|
|
14
|
+
* @typedef {Object} ResizeParams
|
|
15
|
+
* @property {number} [w]
|
|
16
|
+
* @property {number} [h]
|
|
17
|
+
* @property {number} [q]
|
|
18
|
+
* @property {"cover"|"contain"|"fill"|"inside"|"outside"} [fit]
|
|
19
|
+
* @property {"webp"|"avif"|"jpg"|"png"} [f]
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {MediaUrlConfig} config
|
|
24
|
+
*/
|
|
25
|
+
export function createMediaUrl(config) {
|
|
26
|
+
const cdnBase = String(config.cdnBase || "").replace(/\/+$/, "");
|
|
27
|
+
const resizePrefix = (config.resizePrefix || "/_r/").replace(/\/+$/, "/");
|
|
28
|
+
const defaultWidths = config.defaultWidths || [400, 800, 1200, 1600, 2400];
|
|
29
|
+
const tenantId = config.tenantId ? `/p/${config.tenantId}` : "";
|
|
30
|
+
let cdnHost = "";
|
|
31
|
+
try {
|
|
32
|
+
cdnHost = new URL(cdnBase).hostname;
|
|
33
|
+
} catch {
|
|
34
|
+
/* empty cdnBase or invalid URL — url() will still work for absolute inputs */
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function buildQuery(params) {
|
|
38
|
+
const qs = new URLSearchParams();
|
|
39
|
+
if (params?.w) qs.set("w", String(params.w));
|
|
40
|
+
if (params?.h) qs.set("h", String(params.h));
|
|
41
|
+
if (params?.q) qs.set("q", String(params.q));
|
|
42
|
+
if (params?.fit) qs.set("fit", params.fit);
|
|
43
|
+
if (params?.f) qs.set("f", params.f);
|
|
44
|
+
return qs.toString();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Build a CDN URL from a key or a full URL, optionally with resize params. */
|
|
48
|
+
function url(keyOrUrl, params) {
|
|
49
|
+
if (!keyOrUrl) return "";
|
|
50
|
+
const query = buildQuery(params);
|
|
51
|
+
|
|
52
|
+
if (keyOrUrl.startsWith("http://") || keyOrUrl.startsWith("https://")) {
|
|
53
|
+
if (!query) return keyOrUrl;
|
|
54
|
+
try {
|
|
55
|
+
const u = new URL(keyOrUrl);
|
|
56
|
+
if (cdnHost && u.hostname === cdnHost) {
|
|
57
|
+
const r = resizePrefix.replace(/\/$/, "");
|
|
58
|
+
if (tenantId && u.pathname.startsWith(tenantId + "/")) {
|
|
59
|
+
const rest = u.pathname.slice(tenantId.length);
|
|
60
|
+
if (!rest.startsWith(resizePrefix)) {
|
|
61
|
+
u.pathname = tenantId + r + rest;
|
|
62
|
+
}
|
|
63
|
+
} else if (!u.pathname.startsWith(resizePrefix)) {
|
|
64
|
+
u.pathname = r + u.pathname;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const existing = u.search.replace(/^\?/, "");
|
|
68
|
+
u.search = existing ? `${existing}&${query}` : query;
|
|
69
|
+
return u.toString();
|
|
70
|
+
} catch {
|
|
71
|
+
return `${keyOrUrl}${keyOrUrl.includes("?") ? "&" : "?"}${query}`;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const key = keyOrUrl.replace(/^\/+/, "");
|
|
76
|
+
if (!query) return `${cdnBase}${tenantId}/${key}`;
|
|
77
|
+
return `${cdnBase}${tenantId}${resizePrefix}${key}?${query}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Resolve a stored value (key, full URL, or bare name + folder hint) to a CDN URL. */
|
|
81
|
+
function resolve(value, folder, params) {
|
|
82
|
+
if (!value) return "";
|
|
83
|
+
if (value.startsWith("http://") || value.startsWith("https://")) {
|
|
84
|
+
return params ? url(value, params) : value;
|
|
85
|
+
}
|
|
86
|
+
let key = value.replace(/^\/+/, "").replace(/^src\/assets\//, "");
|
|
87
|
+
if (folder && !key.includes("/")) {
|
|
88
|
+
key = `${folder}/${key}`;
|
|
89
|
+
}
|
|
90
|
+
return url(key, params);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function pickWidths(targetW, widths) {
|
|
94
|
+
if (widths && widths.length) return [...widths].sort((a, b) => a - b);
|
|
95
|
+
if (!targetW) return defaultWidths;
|
|
96
|
+
const set = new Set();
|
|
97
|
+
for (const m of [0.5, 1, 1.5, 2]) {
|
|
98
|
+
const w = Math.round(targetW * m);
|
|
99
|
+
if (w >= 200 && w <= 2400) set.add(w);
|
|
100
|
+
}
|
|
101
|
+
return [...set].sort((a, b) => a - b);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Build a srcset string sized at the given widths. */
|
|
105
|
+
function srcset(value, widths, folder, params) {
|
|
106
|
+
if (!value) return "";
|
|
107
|
+
return widths.map((w) => `${resolve(value, folder, { ...params, w })} ${w}w`).join(", ");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Build the {src, srcset, sizes, width, height} props for a responsive <img>. */
|
|
111
|
+
function imageProps(value, opts = {}) {
|
|
112
|
+
if (!value) return { src: "" };
|
|
113
|
+
const { folder, width, height, widths, sizes = "100vw", f = "webp", q = 80, fit } = opts;
|
|
114
|
+
const widthList = pickWidths(width, widths);
|
|
115
|
+
const fallbackW =
|
|
116
|
+
width ?? widthList[Math.min(widthList.length - 1, Math.floor(widthList.length / 2))];
|
|
117
|
+
const baseParams = { f, q, ...(fit ? { fit } : {}) };
|
|
118
|
+
const src = resolve(value, folder, {
|
|
119
|
+
...baseParams,
|
|
120
|
+
w: fallbackW,
|
|
121
|
+
...(height ? { h: height } : {}),
|
|
122
|
+
});
|
|
123
|
+
return {
|
|
124
|
+
src,
|
|
125
|
+
srcset: srcset(value, widthList, folder, baseParams),
|
|
126
|
+
sizes,
|
|
127
|
+
width,
|
|
128
|
+
height,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { url, resolve, srcset, imageProps, defaultWidths };
|
|
133
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mail delivery via Resend — the form module's first (and so far only)
|
|
3
|
+
* provider. The adapter shape is the extension point: a second provider is a
|
|
4
|
+
* new file with the same `{ configured, send }` surface, not a code change.
|
|
5
|
+
*
|
|
6
|
+
* @param {Object} opts
|
|
7
|
+
* @param {string} [opts.apiKey] Missing key → `configured: false`; the forms
|
|
8
|
+
* route answers 503 with a clear message instead of failing mid-send.
|
|
9
|
+
* @param {string} opts.from Sender, e.g. "Site <forms@site.com>". The
|
|
10
|
+
* domain must be verified in Resend (SPF/DKIM) or delivery fails.
|
|
11
|
+
*/
|
|
12
|
+
export function createResendMail({ apiKey, from }) {
|
|
13
|
+
return {
|
|
14
|
+
configured: !!(apiKey && from),
|
|
15
|
+
|
|
16
|
+
async send({ to, subject, text, replyTo }) {
|
|
17
|
+
const res = await fetch("https://api.resend.com/emails", {
|
|
18
|
+
method: "POST",
|
|
19
|
+
headers: {
|
|
20
|
+
Authorization: `Bearer ${apiKey}`,
|
|
21
|
+
"Content-Type": "application/json",
|
|
22
|
+
},
|
|
23
|
+
body: JSON.stringify({
|
|
24
|
+
from,
|
|
25
|
+
to: Array.isArray(to) ? to : [to],
|
|
26
|
+
subject,
|
|
27
|
+
text,
|
|
28
|
+
...(replyTo ? { reply_to: replyTo } : {}),
|
|
29
|
+
}),
|
|
30
|
+
});
|
|
31
|
+
if (!res.ok) {
|
|
32
|
+
let detail = "";
|
|
33
|
+
try { detail = (await res.json()).message ?? ""; } catch { /* body optional */ }
|
|
34
|
+
const err = new Error(`Mail delivery failed (${res.status})${detail ? `: ${detail}` : ""}`);
|
|
35
|
+
err.upstreamStatus = res.status;
|
|
36
|
+
throw err;
|
|
37
|
+
}
|
|
38
|
+
return await res.json();
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
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
|
+
* @property {(items: Array<{collection: string, file: string, data: object}>, message?: string) => Promise<{ok: boolean, sha?: string, commitCount: number}>} writeBatch
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @typedef {Object} MediaAdapter
|
|
60
|
+
* @property {() => Promise<Record<string, string[]>>} listGrouped
|
|
61
|
+
* @property {(folder: string) => Promise<string[]>} listFolder
|
|
62
|
+
* @property {(relPath: string) => string|null} resolveLocalPath
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Auth port.
|
|
67
|
+
*
|
|
68
|
+
* `verify` receives the whole Request — not just the Authorization header —
|
|
69
|
+
* so adapters that authenticate from a different header or a cookie (e.g.
|
|
70
|
+
* Cloudflare Access) are first-class rather than special cases. It is async
|
|
71
|
+
* by contract even when an implementation answers synchronously, so callers
|
|
72
|
+
* can always await it.
|
|
73
|
+
*
|
|
74
|
+
* @typedef {Object} AuthAdapter
|
|
75
|
+
* @property {boolean} configured
|
|
76
|
+
* @property {(request: Request) => Promise<{login: string, name?: string, role: string}|null>} verify
|
|
77
|
+
* @property {(tenantId: string) => string} issueMediaToken
|
|
78
|
+
* @property {(login: string, name?: string) => string} [issueSessionToken] github-oauth only
|
|
79
|
+
* @property {() => string} [issueOAuthState] github-oauth only
|
|
80
|
+
* @property {(state: string) => boolean} [verifyOAuthState] github-oauth only
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @typedef {Object} BuildAdapter
|
|
85
|
+
* @property {boolean} configured
|
|
86
|
+
* @property {(opts?: {branch?: string, sha?: string}) => Promise<object>} getDeployStatus
|
|
87
|
+
*/
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @typedef {Object} TemplateSummary
|
|
91
|
+
* @property {string} name
|
|
92
|
+
* @property {string} slug
|
|
93
|
+
* @property {number} blockCount
|
|
94
|
+
*/
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @typedef {Object} TemplatesAdapter
|
|
98
|
+
* @property {() => Promise<TemplateSummary[]>} list
|
|
99
|
+
* @property {(slug: string) => Promise<object|null>} get
|
|
100
|
+
* @property {(slug: string, data: object) => Promise<void>} put
|
|
101
|
+
* @property {(slug: string) => Promise<boolean>} delete
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createRequire } from "module";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
|
|
5
|
+
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
|
6
|
+
const require_ = createRequire(import.meta.url);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Locate the installed `@stelstone/admin-ui` package.
|
|
10
|
+
*
|
|
11
|
+
* The admin UI is a *peer* of this package, not a dependency: it is a
|
|
12
|
+
* deployment artifact the site owns, exactly like the site's Worker assets.
|
|
13
|
+
* That means it must be located through module resolution rather than by
|
|
14
|
+
* guessing a relative path — the previous `../../admin-ui/dist` guess happened
|
|
15
|
+
* to work for a flat npm layout and silently resolved to nothing whenever npm
|
|
16
|
+
* nested or deduped the package elsewhere.
|
|
17
|
+
*
|
|
18
|
+
* Node only. A Worker serves the SPA from its assets binding and never calls
|
|
19
|
+
* any of this.
|
|
20
|
+
*
|
|
21
|
+
* @returns {string|null} Absolute path to the package root.
|
|
22
|
+
*/
|
|
23
|
+
function resolvePackageDir() {
|
|
24
|
+
try {
|
|
25
|
+
return path.dirname(require_.resolve("@stelstone/admin-ui/package.json"));
|
|
26
|
+
} catch {
|
|
27
|
+
// Monorepo or linked checkout where the package is a sibling directory
|
|
28
|
+
// rather than an installed dependency.
|
|
29
|
+
const sibling = path.resolve(__dirname, "../../admin-ui");
|
|
30
|
+
return fs.existsSync(path.join(sibling, "package.json")) ? sibling : null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Absolute path to the built admin-ui dist directory, or null when the admin
|
|
36
|
+
* UI is not installed or has not been built yet.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveAdminUiDir() {
|
|
39
|
+
const pkgDir = resolvePackageDir();
|
|
40
|
+
if (!pkgDir) return null;
|
|
41
|
+
const dist = path.join(pkgDir, "dist");
|
|
42
|
+
return fs.existsSync(path.join(dist, "index.html")) ? dist : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The admin-ui source root (the Vite project root) for dev/HMR mode.
|
|
47
|
+
* Only available when the source is present — a published tarball ships
|
|
48
|
+
* `dist` alone.
|
|
49
|
+
*/
|
|
50
|
+
export function resolveAdminUiSourceDir() {
|
|
51
|
+
const pkgDir = resolvePackageDir();
|
|
52
|
+
if (!pkgDir) return null;
|
|
53
|
+
return fs.existsSync(path.join(pkgDir, "vite.config.js")) &&
|
|
54
|
+
fs.existsSync(path.join(pkgDir, "index.html"))
|
|
55
|
+
? pkgDir
|
|
56
|
+
: null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The version of the admin UI this server would actually serve.
|
|
61
|
+
*
|
|
62
|
+
* Read from the resolved package at runtime, never baked in at build time:
|
|
63
|
+
* the server and the admin UI are versioned independently, so a constant
|
|
64
|
+
* captured when the server was released would describe a different bundle
|
|
65
|
+
* than the one on disk.
|
|
66
|
+
*
|
|
67
|
+
* @returns {string|null}
|
|
68
|
+
*/
|
|
69
|
+
export function resolveAdminUiVersion() {
|
|
70
|
+
const pkgDir = resolvePackageDir();
|
|
71
|
+
if (!pkgDir) return null;
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(fs.readFileSync(path.join(pkgDir, "package.json"), "utf-8")).version ?? null;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|