@zeropress/build-core 0.1.11 → 0.1.12
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 +2 -1
- package/src/api/build-site.js +95 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeropress/build-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"description": "Shared ZeroPress build core",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"node": ">=18.18.0"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
+
"@zeropress/slug-policy": "^0.1.0",
|
|
36
37
|
"@zeropress/preview-data-validator": "^0.4.1",
|
|
37
38
|
"@zeropress/theme-validator": "^0.3.0",
|
|
38
39
|
"highlight.js": "^11.11.1",
|
package/src/api/build-site.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { assertPreviewData } from '@zeropress/preview-data-validator';
|
|
3
|
+
import { isSafeSlugSegment, normalizeStoredSlug } from '@zeropress/slug-policy';
|
|
3
4
|
import { validateThemeFiles } from '@zeropress/theme-validator';
|
|
4
5
|
import { AssetProcessor } from '../assets/asset-processor.js';
|
|
5
6
|
import { renderDocumentContent } from '../render/content-renderer.js';
|
|
@@ -17,9 +18,11 @@ const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD';
|
|
|
17
18
|
const DEFAULT_TIME_FORMAT = 'HH:mm';
|
|
18
19
|
const DEFAULT_TIMEZONE = 'UTC';
|
|
19
20
|
const DEFAULT_LOCALE = 'en-US';
|
|
21
|
+
const OUTPUT_PATH_CONTROL_CHAR_PATTERN = /[\u0000-\u001F\u007F]/;
|
|
20
22
|
export async function buildSite(input) {
|
|
21
23
|
const options = { ...DEFAULT_OPTIONS, ...(input.options || {}) };
|
|
22
24
|
const state = await createBuildState(input, options);
|
|
25
|
+
assertPlannedOutputPathsSafe(state);
|
|
23
26
|
|
|
24
27
|
for (const route of state.renderData.indexRoutes) {
|
|
25
28
|
await renderRoute(state, 'index', route);
|
|
@@ -959,7 +962,9 @@ function isAbsoluteUrl(value) {
|
|
|
959
962
|
}
|
|
960
963
|
|
|
961
964
|
async function writeOutput(writer, summaries, path, content, contentType) {
|
|
962
|
-
const
|
|
965
|
+
const rawPath = String(path || '');
|
|
966
|
+
const normalizedPath = normalizeOutputPath(rawPath);
|
|
967
|
+
assertSafeRelativeOutputPath(rawPath, normalizedPath);
|
|
963
968
|
await writer.write({ path: normalizedPath, content, contentType });
|
|
964
969
|
summaries.push({
|
|
965
970
|
path: normalizedPath,
|
|
@@ -989,6 +994,95 @@ function normalizeOutputPath(filePath) {
|
|
|
989
994
|
return String(filePath || '').replace(/^\/+/, '');
|
|
990
995
|
}
|
|
991
996
|
|
|
997
|
+
function assertPlannedOutputPathsSafe(state) {
|
|
998
|
+
for (const post of state.renderData.posts) {
|
|
999
|
+
assertSafeSlugDerivedOutputPath(post.slug, `posts/${encodeSlugSegment(post.slug)}/index.html`);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
for (const page of state.renderData.pages) {
|
|
1003
|
+
assertSafeSlugDerivedOutputPath(page.slug, `${encodeSlugSegment(page.slug)}/index.html`);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
for (const category of state.previewData.content.categories) {
|
|
1007
|
+
assertSafeSlugDerivedOutputPath(category.slug, `categories/${encodeSlugSegment(category.slug)}/index.html`);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
for (const tag of state.previewData.content.tags) {
|
|
1011
|
+
assertSafeSlugDerivedOutputPath(tag.slug, `tags/${encodeSlugSegment(tag.slug)}/index.html`);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
const plannedPaths = [
|
|
1015
|
+
...state.renderData.indexRoutes.map((route) => routePathToOutputPath(route.path)),
|
|
1016
|
+
...state.renderData.archiveRoutes.map((route) => routePathToOutputPath(route.path)),
|
|
1017
|
+
...state.renderData.categoryRoutes.map((route) => routePathToOutputPath(route.path)),
|
|
1018
|
+
...state.renderData.tagRoutes.map((route) => routePathToOutputPath(route.path)),
|
|
1019
|
+
...state.renderData.posts.map((post) => `posts/${encodeSlugSegment(post.slug)}/index.html`),
|
|
1020
|
+
...state.renderData.pages.map((page) => `${encodeSlugSegment(page.slug)}/index.html`),
|
|
1021
|
+
...state.assetOutputs.map((assetOutput) => assetOutput.path),
|
|
1022
|
+
];
|
|
1023
|
+
|
|
1024
|
+
if (state.options.generateSpecialFiles) {
|
|
1025
|
+
plannedPaths.push('404.html', 'robots.txt', 'meta.json');
|
|
1026
|
+
if (hasCanonicalSiteUrl(state.previewData.site.url)) {
|
|
1027
|
+
plannedPaths.push('sitemap.xml', 'feed.xml');
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
for (const plannedPath of plannedPaths) {
|
|
1032
|
+
const rawPath = String(plannedPath || '');
|
|
1033
|
+
const normalizedPath = normalizeOutputPath(rawPath);
|
|
1034
|
+
assertSafeRelativeOutputPath(rawPath, normalizedPath);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function assertSafeSlugDerivedOutputPath(rawSlug, outputPath) {
|
|
1039
|
+
const originalSlug = typeof rawSlug === 'string' ? rawSlug : '';
|
|
1040
|
+
const decodedSlug = encodeSlugSegment(rawSlug);
|
|
1041
|
+
|
|
1042
|
+
if (
|
|
1043
|
+
!isSafeSlugPathSegment(originalSlug) ||
|
|
1044
|
+
!isSafeSlugPathSegment(decodedSlug)
|
|
1045
|
+
) {
|
|
1046
|
+
throw new Error(`Unsafe output path detected: ${outputPath}`);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function isSafeSlugPathSegment(value) {
|
|
1051
|
+
return isSafeSlugSegment(value);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function assertSafeRelativeOutputPath(rawPath, normalizedPath = normalizeOutputPath(rawPath)) {
|
|
1055
|
+
const originalPath = String(rawPath || '');
|
|
1056
|
+
const candidatePath = String(normalizedPath || '');
|
|
1057
|
+
const normalizedSeparators = candidatePath.replace(/\\/g, '/');
|
|
1058
|
+
|
|
1059
|
+
if (originalPath.trim() === '' || candidatePath.trim() === '') {
|
|
1060
|
+
throw new Error(`Unsafe output path detected: ${originalPath || candidatePath || '<empty>'}`);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
if (OUTPUT_PATH_CONTROL_CHAR_PATTERN.test(originalPath) || OUTPUT_PATH_CONTROL_CHAR_PATTERN.test(candidatePath)) {
|
|
1064
|
+
throw new Error(`Unsafe output path detected: ${originalPath}`);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
if (candidatePath.includes('%')) {
|
|
1068
|
+
throw new Error(`Unsafe output path detected: ${originalPath}`);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
if (
|
|
1072
|
+
originalPath.startsWith('/') ||
|
|
1073
|
+
originalPath.startsWith('\\') ||
|
|
1074
|
+
/^[A-Za-z]:[\\/]/.test(originalPath) ||
|
|
1075
|
+
normalizedSeparators.startsWith('//')
|
|
1076
|
+
) {
|
|
1077
|
+
throw new Error(`Unsafe output path detected: ${originalPath}`);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
const segments = normalizedSeparators.split('/');
|
|
1081
|
+
if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
|
|
1082
|
+
throw new Error(`Unsafe output path detected: ${originalPath}`);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
992
1086
|
function getContentSize(content) {
|
|
993
1087
|
return typeof content === 'string' ? Buffer.byteLength(content) : content.byteLength;
|
|
994
1088
|
}
|
|
@@ -1120,23 +1214,6 @@ function encodeSlugSegment(slug) {
|
|
|
1120
1214
|
return normalizeStoredSlug(slug);
|
|
1121
1215
|
}
|
|
1122
1216
|
|
|
1123
|
-
function normalizeStoredSlug(slug) {
|
|
1124
|
-
if (typeof slug !== 'string') {
|
|
1125
|
-
return '';
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
const trimmed = slug.trim();
|
|
1129
|
-
if (!trimmed.includes('%')) {
|
|
1130
|
-
return trimmed;
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
try {
|
|
1134
|
-
return decodeURIComponent(trimmed);
|
|
1135
|
-
} catch {
|
|
1136
|
-
return trimmed;
|
|
1137
|
-
}
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
1217
|
function decodeRoutePath(routePath) {
|
|
1141
1218
|
try {
|
|
1142
1219
|
return decodeURI(routePath);
|