@minamocms/framework 0.0.0-stage → 0.10.2
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/CHANGELOG.md +240 -0
- package/LICENSE +105 -0
- package/README.md +133 -2
- package/dist/boot.d.ts +19 -0
- package/dist/boot.js +22 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +44 -0
- package/dist/create-site/cli.d.ts +2 -0
- package/dist/create-site/cli.js +31 -0
- package/dist/create-site/generate-site.d.ts +12 -0
- package/dist/create-site/generate-site.js +168 -0
- package/dist/create-site/mint-token-cli.d.ts +2 -0
- package/dist/create-site/mint-token-cli.js +26 -0
- package/dist/create-site/mint-token.d.ts +7 -0
- package/dist/create-site/mint-token.js +49 -0
- package/dist/create-site/template/AGENTS.md +473 -0
- package/dist/create-site/template/content/menus/footerCompany.json +9 -0
- package/dist/create-site/template/content/menus/footerProduct.json +9 -0
- package/dist/create-site/template/content/menus/footerResources.json +9 -0
- package/dist/create-site/template/content/menus/main.json +9 -0
- package/dist/create-site/template/content/pages/404.json +23 -0
- package/dist/create-site/template/content/pages/about/careers.json +22 -0
- package/dist/create-site/template/content/pages/about/team.json +22 -0
- package/dist/create-site/template/content/pages/about.json +22 -0
- package/dist/create-site/template/content/pages/docs/deployment.json +22 -0
- package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +22 -0
- package/dist/create-site/template/content/pages/docs/getting-started.json +24 -0
- package/dist/create-site/template/content/pages/docs.json +22 -0
- package/dist/create-site/template/content/pages/index.json +182 -0
- package/dist/create-site/template/content/redirects.json +4 -0
- package/dist/create-site/template/dockerignore +6 -0
- package/dist/create-site/template/gitignore +11 -0
- package/dist/create-site/template/theme/assets/site.js +49 -0
- package/dist/create-site/template/theme/assets/style.css +1223 -0
- package/dist/create-site/template/theme/blocks/button.liquid +21 -0
- package/dist/create-site/template/theme/blocks/comparison-row.liquid +19 -0
- package/dist/create-site/template/theme/blocks/faq-item.liquid +22 -0
- package/dist/create-site/template/theme/blocks/feature-card.liquid +23 -0
- package/dist/create-site/template/theme/blocks/logo-mark.liquid +13 -0
- package/dist/create-site/template/theme/blocks/pricing-tier.liquid +42 -0
- package/dist/create-site/template/theme/blocks/step-card.liquid +19 -0
- package/dist/create-site/template/theme/blocks/testimonial-card.liquid +25 -0
- package/dist/create-site/template/theme/config/site_settings.json +11 -0
- package/dist/create-site/template/theme/layouts/theme.liquid +102 -0
- package/dist/create-site/template/theme/sections/comparison.liquid +41 -0
- package/dist/create-site/template/theme/sections/cta-banner.liquid +36 -0
- package/dist/create-site/template/theme/sections/faq.liquid +27 -0
- package/dist/create-site/template/theme/sections/feature-grid.liquid +27 -0
- package/dist/create-site/template/theme/sections/hero.liquid +48 -0
- package/dist/create-site/template/theme/sections/how-it-works.liquid +27 -0
- package/dist/create-site/template/theme/sections/pricing.liquid +27 -0
- package/dist/create-site/template/theme/sections/social-proof.liquid +21 -0
- package/dist/create-site/template/theme/sections/testimonials.liquid +27 -0
- package/dist/create-site/template/theme/snippets/icon.liquid +24 -0
- package/dist/create-site/template/theme/snippets/logo.liquid +7 -0
- package/dist/create-site/template/theme/snippets/responsive-media.liquid +107 -0
- package/dist/create-site/template/theme/snippets/site-name.liquid +1 -0
- package/dist/create-site/template/vhost/Dockerfile +33 -0
- package/dist/create-site/template/vhost/docker-entrypoint.sh +58 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/media/drivers/driver.d.ts +11 -0
- package/dist/media/drivers/driver.js +1 -0
- package/dist/media/drivers/local-fs-driver.d.ts +3 -0
- package/dist/media/drivers/local-fs-driver.js +94 -0
- package/dist/media/filename.d.ts +2 -0
- package/dist/media/filename.js +54 -0
- package/dist/media/manage-media.d.ts +19 -0
- package/dist/media/manage-media.js +45 -0
- package/dist/media/seed-media-cli.d.ts +2 -0
- package/dist/media/seed-media-cli.js +22 -0
- package/dist/media/seed-media.d.ts +11 -0
- package/dist/media/seed-media.js +76 -0
- package/dist/migrations/index.d.ts +3 -0
- package/dist/migrations/index.js +83 -0
- package/dist/pull-site/cli.d.ts +2 -0
- package/dist/pull-site/cli.js +78 -0
- package/dist/pull-site/prompt-token.d.ts +16 -0
- package/dist/pull-site/prompt-token.js +69 -0
- package/dist/pull-site/prompts.d.ts +22 -0
- package/dist/pull-site/prompts.js +127 -0
- package/dist/pull-site/pull-site.d.ts +23 -0
- package/dist/pull-site/pull-site.js +211 -0
- package/dist/renderer/engine.d.ts +2 -0
- package/dist/renderer/engine.js +48 -0
- package/dist/renderer/render-cache.d.ts +11 -0
- package/dist/renderer/render-cache.js +11 -0
- package/dist/renderer/render-page.d.ts +34 -0
- package/dist/renderer/render-page.js +209 -0
- package/dist/renderer/theme-templates.d.ts +7 -0
- package/dist/renderer/theme-templates.js +82 -0
- package/dist/routes/admin-redirect.d.ts +5 -0
- package/dist/routes/admin-redirect.js +26 -0
- package/dist/routes/assets.d.ts +6 -0
- package/dist/routes/assets.js +30 -0
- package/dist/routes/batch.d.ts +10 -0
- package/dist/routes/batch.js +148 -0
- package/dist/routes/capabilities.d.ts +6 -0
- package/dist/routes/capabilities.js +27 -0
- package/dist/routes/content.d.ts +8 -0
- package/dist/routes/content.js +169 -0
- package/dist/routes/deleted-pages.d.ts +8 -0
- package/dist/routes/deleted-pages.js +9 -0
- package/dist/routes/drafts.d.ts +10 -0
- package/dist/routes/drafts.js +73 -0
- package/dist/routes/git.d.ts +8 -0
- package/dist/routes/git.js +158 -0
- package/dist/routes/index.d.ts +12 -0
- package/dist/routes/index.js +86 -0
- package/dist/routes/links.d.ts +8 -0
- package/dist/routes/links.js +18 -0
- package/dist/routes/media-public.d.ts +11 -0
- package/dist/routes/media-public.js +125 -0
- package/dist/routes/media.d.ts +9 -0
- package/dist/routes/media.js +80 -0
- package/dist/routes/menus.d.ts +8 -0
- package/dist/routes/menus.js +148 -0
- package/dist/routes/preview-revision.d.ts +10 -0
- package/dist/routes/preview-revision.js +96 -0
- package/dist/routes/preview.d.ts +10 -0
- package/dist/routes/preview.js +56 -0
- package/dist/routes/public.d.ts +10 -0
- package/dist/routes/public.js +123 -0
- package/dist/routes/publish.d.ts +10 -0
- package/dist/routes/publish.js +139 -0
- package/dist/routes/redirects.d.ts +8 -0
- package/dist/routes/redirects.js +132 -0
- package/dist/routes/search-public.d.ts +6 -0
- package/dist/routes/search-public.js +104 -0
- package/dist/routes/search.d.ts +8 -0
- package/dist/routes/search.js +15 -0
- package/dist/routes/settings.d.ts +10 -0
- package/dist/routes/settings.js +68 -0
- package/dist/routes/sitemap.d.ts +7 -0
- package/dist/routes/sitemap.js +79 -0
- package/dist/routes/theme.d.ts +10 -0
- package/dist/routes/theme.js +116 -0
- package/dist/schemas/instance.schema.json +17 -0
- package/dist/schemas/menu.schema.json +24 -0
- package/dist/schemas/page.schema.json +26 -0
- package/dist/schemas/redirects.schema.json +24 -0
- package/dist/search/drivers/driver.d.ts +9 -0
- package/dist/search/drivers/driver.js +1 -0
- package/dist/search/drivers/node-sqlite-driver.d.ts +7 -0
- package/dist/search/drivers/node-sqlite-driver.js +19 -0
- package/dist/search/query-content.d.ts +32 -0
- package/dist/search/query-content.js +207 -0
- package/dist/search/query-index.d.ts +5 -0
- package/dist/search/query-index.js +21 -0
- package/dist/search/rebuild-index.d.ts +3 -0
- package/dist/search/rebuild-index.js +320 -0
- package/dist/server-config.d.ts +23 -0
- package/dist/server-config.js +237 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +235 -0
- package/dist/services/batch.d.ts +35 -0
- package/dist/services/batch.js +145 -0
- package/dist/services/checkpoint.d.ts +4 -0
- package/dist/services/checkpoint.js +45 -0
- package/dist/services/content-read.d.ts +27 -0
- package/dist/services/content-read.js +151 -0
- package/dist/services/delete-content.d.ts +12 -0
- package/dist/services/delete-content.js +166 -0
- package/dist/services/deleted-pages.d.ts +11 -0
- package/dist/services/deleted-pages.js +83 -0
- package/dist/services/dev-tunnel.d.ts +5 -0
- package/dist/services/dev-tunnel.js +5 -0
- package/dist/services/drafts.d.ts +16 -0
- package/dist/services/drafts.js +164 -0
- package/dist/services/etag.d.ts +2 -0
- package/dist/services/etag.js +30 -0
- package/dist/services/fs-walk.d.ts +1 -0
- package/dist/services/fs-walk.js +26 -0
- package/dist/services/git-commit.d.ts +3 -0
- package/dist/services/git-commit.js +12 -0
- package/dist/services/git-history.d.ts +28 -0
- package/dist/services/git-history.js +131 -0
- package/dist/services/git-revert.d.ts +12 -0
- package/dist/services/git-revert.js +140 -0
- package/dist/services/git.d.ts +16 -0
- package/dist/services/git.js +127 -0
- package/dist/services/interval-job.d.ts +4 -0
- package/dist/services/interval-job.js +9 -0
- package/dist/services/ip-allowlist.d.ts +7 -0
- package/dist/services/ip-allowlist.js +31 -0
- package/dist/services/links.d.ts +22 -0
- package/dist/services/links.js +169 -0
- package/dist/services/manage-menus.d.ts +15 -0
- package/dist/services/manage-menus.js +138 -0
- package/dist/services/manage-redirects.d.ts +25 -0
- package/dist/services/manage-redirects.js +156 -0
- package/dist/services/menu-references.d.ts +3 -0
- package/dist/services/menu-references.js +38 -0
- package/dist/services/menus.d.ts +9 -0
- package/dist/services/menus.js +43 -0
- package/dist/services/migration-runner.d.ts +16 -0
- package/dist/services/migration-runner.js +169 -0
- package/dist/services/mime-types.d.ts +3 -0
- package/dist/services/mime-types.js +43 -0
- package/dist/services/move.d.ts +16 -0
- package/dist/services/move.js +236 -0
- package/dist/services/path-safety.d.ts +6 -0
- package/dist/services/path-safety.js +112 -0
- package/dist/services/pid-file.d.ts +13 -0
- package/dist/services/pid-file.js +54 -0
- package/dist/services/post-urls.d.ts +3 -0
- package/dist/services/post-urls.js +27 -0
- package/dist/services/prepared-operation.d.ts +4 -0
- package/dist/services/prepared-operation.js +1 -0
- package/dist/services/publish.d.ts +15 -0
- package/dist/services/publish.js +313 -0
- package/dist/services/rate-limit-config.d.ts +9 -0
- package/dist/services/rate-limit-config.js +22 -0
- package/dist/services/redirects.d.ts +27 -0
- package/dist/services/redirects.js +175 -0
- package/dist/services/reindex-on-write.d.ts +3 -0
- package/dist/services/reindex-on-write.js +30 -0
- package/dist/services/resolve-blog-url.d.ts +11 -0
- package/dist/services/resolve-blog-url.js +31 -0
- package/dist/services/resolve-url.d.ts +11 -0
- package/dist/services/resolve-url.js +20 -0
- package/dist/services/site-settings.d.ts +28 -0
- package/dist/services/site-settings.js +116 -0
- package/dist/services/startup-checks.d.ts +10 -0
- package/dist/services/startup-checks.js +81 -0
- package/dist/services/static-file.d.ts +7 -0
- package/dist/services/static-file.js +37 -0
- package/dist/services/theme-component-file.d.ts +6 -0
- package/dist/services/theme-component-file.js +39 -0
- package/dist/services/theme-files.d.ts +33 -0
- package/dist/services/theme-files.js +148 -0
- package/dist/services/theme-page-templates.d.ts +7 -0
- package/dist/services/theme-page-templates.js +53 -0
- package/dist/services/theme-schemas.d.ts +2 -0
- package/dist/services/theme-schemas.js +137 -0
- package/dist/services/token-auth.d.ts +10 -0
- package/dist/services/token-auth.js +64 -0
- package/dist/services/urls.d.ts +2 -0
- package/dist/services/urls.js +20 -0
- package/dist/services/validation.d.ts +26 -0
- package/dist/services/validation.js +195 -0
- package/dist/services/write-queue.d.ts +1 -0
- package/dist/services/write-queue.js +14 -0
- package/dist/site-check/cli.d.ts +2 -0
- package/dist/site-check/cli.js +48 -0
- package/dist/site-check/run-check.d.ts +12 -0
- package/dist/site-check/run-check.js +188 -0
- package/dist/site-sync/cli-helpers.d.ts +23 -0
- package/dist/site-sync/cli-helpers.js +75 -0
- package/dist/site-sync/deploy.d.ts +36 -0
- package/dist/site-sync/deploy.js +124 -0
- package/dist/site-sync/live-content.d.ts +30 -0
- package/dist/site-sync/live-content.js +68 -0
- package/dist/site-sync/prompts.d.ts +30 -0
- package/dist/site-sync/prompts.js +237 -0
- package/dist/site-sync/pull-cli.d.ts +2 -0
- package/dist/site-sync/pull-cli.js +75 -0
- package/dist/site-sync/pull-site.d.ts +28 -0
- package/dist/site-sync/pull-site.js +171 -0
- package/dist/site-sync/push-cli.d.ts +2 -0
- package/dist/site-sync/push-cli.js +268 -0
- package/dist/site-sync/push-site.d.ts +96 -0
- package/dist/site-sync/push-site.js +376 -0
- package/dist/site-sync/remote-site.d.ts +38 -0
- package/dist/site-sync/remote-site.js +122 -0
- package/dist/site-sync/sync-record.d.ts +26 -0
- package/dist/site-sync/sync-record.js +76 -0
- package/dist/site-sync/theme-sync.d.ts +34 -0
- package/dist/site-sync/theme-sync.js +128 -0
- package/dist/stop-site/cli.d.ts +2 -0
- package/dist/stop-site/cli.js +28 -0
- package/dist/stop-site/stop-site.d.ts +26 -0
- package/dist/stop-site/stop-site.js +56 -0
- package/dist/theme-state.d.ts +20 -0
- package/dist/theme-state.js +33 -0
- package/dist/upgrade/changelog.d.ts +8 -0
- package/dist/upgrade/changelog.js +46 -0
- package/dist/upgrade/finish.d.ts +9 -0
- package/dist/upgrade/finish.js +30 -0
- package/dist/upgrade/owned-files.d.ts +11 -0
- package/dist/upgrade/owned-files.js +42 -0
- package/dist/upgrade/package-names.d.ts +4 -0
- package/dist/upgrade/package-names.js +26 -0
- package/dist/upgrade/upgrade-cli.d.ts +2 -0
- package/dist/upgrade/upgrade-cli.js +170 -0
- package/package.json +64 -4
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { listFilesRecursively } from "./fs-walk.js";
|
|
4
|
+
import { GitOperationError, commitPaths } from "./git.js";
|
|
5
|
+
import { validateContent } from "./validation.js";
|
|
6
|
+
import { enqueue } from "./write-queue.js";
|
|
7
|
+
export class MigrationError extends Error {
|
|
8
|
+
reason;
|
|
9
|
+
constructor(reason, message, options) {
|
|
10
|
+
super(message, options);
|
|
11
|
+
this.name = 'MigrationError';
|
|
12
|
+
this.reason = reason;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
// Walks the migration chain one step at a time until currentVersion is
|
|
16
|
+
// reached. Throws if a step is missing, or if a migration doesn't
|
|
17
|
+
// actually advance the version (a defensive check against a broken
|
|
18
|
+
// migration function).
|
|
19
|
+
function applyMigrationChain(content, migrations, currentVersion, path) {
|
|
20
|
+
let current = content;
|
|
21
|
+
const initialVersion = current.schemaVersion;
|
|
22
|
+
if (typeof initialVersion !== 'number') {
|
|
23
|
+
throw new MigrationError('migration-failed', `"${path}" has no numeric schemaVersion`);
|
|
24
|
+
}
|
|
25
|
+
let version = initialVersion;
|
|
26
|
+
while (version < currentVersion) {
|
|
27
|
+
const migrate = migrations[version];
|
|
28
|
+
if (!migrate) {
|
|
29
|
+
throw new MigrationError('migration-failed', `No migration registered for schemaVersion ${version} ("${path}")`);
|
|
30
|
+
}
|
|
31
|
+
const next = migrate(current);
|
|
32
|
+
const nextVersion = next.schemaVersion;
|
|
33
|
+
if (typeof nextVersion !== 'number' || nextVersion <= version) {
|
|
34
|
+
throw new MigrationError('migration-failed', `Migration from schemaVersion ${version} did not advance schemaVersion ("${path}")`);
|
|
35
|
+
}
|
|
36
|
+
current = next;
|
|
37
|
+
version = nextVersion;
|
|
38
|
+
}
|
|
39
|
+
return current;
|
|
40
|
+
}
|
|
41
|
+
// Hand-rolled fs snapshot/restore, matching publish.ts's established
|
|
42
|
+
// shape - but simpler: every file the write phase touches already
|
|
43
|
+
// existed with real prior bytes (F3's skip logic guarantees this), so
|
|
44
|
+
// there is no "did this file exist before" branch to carry. A
|
|
45
|
+
// relocated file (targetPath !== path) is undone by removing whatever
|
|
46
|
+
// landed at targetPath and restoring the original at its original path.
|
|
47
|
+
function rollback(files) {
|
|
48
|
+
const failures = [];
|
|
49
|
+
for (const file of files) {
|
|
50
|
+
try {
|
|
51
|
+
if (file.targetPath !== file.path) {
|
|
52
|
+
try {
|
|
53
|
+
unlinkSync(file.targetPath);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error.code !== 'ENOENT') {
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
writeFileSync(file.path, file.originalBytes);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
failures.push(error);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return failures;
|
|
68
|
+
}
|
|
69
|
+
async function runMigrationsJob(config, themeSchemas, migrations, currentVersion, author, options) {
|
|
70
|
+
// content/posts/ is a legacy root: posts were folded into pages, and
|
|
71
|
+
// no code walks a postsRoot for anything else any more (see
|
|
72
|
+
// config.ts) - it's computed locally, here only, purely to find any
|
|
73
|
+
// leftover legacy files this migration needs to relocate. A site
|
|
74
|
+
// that never had posts has no such directory; listFilesRecursively
|
|
75
|
+
// already returns [] for a missing root, same as every other caller.
|
|
76
|
+
const legacyPostsRoot = join(config.contentRoot, 'posts');
|
|
77
|
+
// Named walks (pages/menus/legacy posts), not one broad walk of
|
|
78
|
+
// contentRoot - see content-read.ts's listContent for the identical
|
|
79
|
+
// reasoning (Group N nested draftsRoot/redirectsPath inside
|
|
80
|
+
// contentRoot; a broad walk would double-migrate drafts and try to
|
|
81
|
+
// validate redirects.json as a page). base stays config.contentRoot
|
|
82
|
+
// for pages/menus so relativePath matches what validateContent
|
|
83
|
+
// expects. Legacy posts get their own targetPath: content/posts/
|
|
84
|
+
// was always flat (no nested slugs), so relocating each file to
|
|
85
|
+
// content/pages/blog/<slug>.json preserves its old /blog/<slug> URL
|
|
86
|
+
// as an ordinary nested page - no reserved namespace needed any more.
|
|
87
|
+
const files = [
|
|
88
|
+
...listFilesRecursively(config.pagesRoot, config.contentRoot, '.json').map((rel) => ({
|
|
89
|
+
path: join(config.contentRoot, rel),
|
|
90
|
+
targetPath: join(config.contentRoot, rel),
|
|
91
|
+
relativePath: rel,
|
|
92
|
+
})),
|
|
93
|
+
...listFilesRecursively(config.menusRoot, config.contentRoot, '.json').map((rel) => ({
|
|
94
|
+
path: join(config.contentRoot, rel),
|
|
95
|
+
targetPath: join(config.contentRoot, rel),
|
|
96
|
+
relativePath: rel,
|
|
97
|
+
})),
|
|
98
|
+
...listFilesRecursively(legacyPostsRoot, legacyPostsRoot, '.json').map((rel) => ({
|
|
99
|
+
path: join(legacyPostsRoot, rel),
|
|
100
|
+
targetPath: join(config.contentRoot, 'pages', 'blog', rel),
|
|
101
|
+
relativePath: join('pages', 'blog', rel),
|
|
102
|
+
})),
|
|
103
|
+
...listFilesRecursively(config.draftsRoot, config.draftsRoot, '.json').map((rel) => ({
|
|
104
|
+
path: join(config.draftsRoot, rel),
|
|
105
|
+
targetPath: join(config.draftsRoot, rel),
|
|
106
|
+
relativePath: rel,
|
|
107
|
+
})),
|
|
108
|
+
];
|
|
109
|
+
// Compute every migration before writing anything. If any file's
|
|
110
|
+
// migration or validation throws, everything computed for other
|
|
111
|
+
// files so far is simply discarded - a mid-list failure aborts with
|
|
112
|
+
// literally zero writes having happened (F4's primary scenario).
|
|
113
|
+
const toMigrate = [];
|
|
114
|
+
for (const { path, targetPath, relativePath } of files) {
|
|
115
|
+
const originalBytes = readFileSync(path);
|
|
116
|
+
const parsed = JSON.parse(originalBytes.toString('utf-8'));
|
|
117
|
+
const schemaVersion = parsed.schemaVersion;
|
|
118
|
+
// A legacy posts/ file can never already be at currentVersion: the
|
|
119
|
+
// posts/ directory only ever held content written before this very
|
|
120
|
+
// migration existed, so this skip and the relocation above never
|
|
121
|
+
// conflict in practice.
|
|
122
|
+
if (typeof schemaVersion === 'number' && schemaVersion >= currentVersion) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const migrated = applyMigrationChain(parsed, migrations, currentVersion, path);
|
|
126
|
+
const result = validateContent(relativePath, migrated, themeSchemas);
|
|
127
|
+
if (!result.valid) {
|
|
128
|
+
throw new MigrationError('validation-failed', `Migrated content at "${path}" failed validation: ${JSON.stringify(result.errors)}`);
|
|
129
|
+
}
|
|
130
|
+
const migratedBytes = Buffer.from(JSON.stringify(migrated, null, 2));
|
|
131
|
+
toMigrate.push({ path, targetPath, originalBytes, migratedBytes });
|
|
132
|
+
}
|
|
133
|
+
if (toMigrate.length === 0) {
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
for (const file of toMigrate) {
|
|
138
|
+
if (file.targetPath !== file.path) {
|
|
139
|
+
mkdirSync(dirname(file.targetPath), { recursive: true });
|
|
140
|
+
}
|
|
141
|
+
writeFileSync(file.targetPath, file.migratedBytes);
|
|
142
|
+
if (file.targetPath !== file.path) {
|
|
143
|
+
unlinkSync(file.path);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const committedPaths = new Set();
|
|
147
|
+
for (const file of toMigrate) {
|
|
148
|
+
committedPaths.add(file.path);
|
|
149
|
+
committedPaths.add(file.targetPath);
|
|
150
|
+
}
|
|
151
|
+
if (options.commit !== false) {
|
|
152
|
+
commitPaths(config.siteRoot, [...committedPaths], `chore: migrate content to schema version ${currentVersion}`, author);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
const failures = rollback(toMigrate);
|
|
157
|
+
if (failures.length > 0) {
|
|
158
|
+
throw new MigrationError('rollback-failed', 'Migration run failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause: error });
|
|
159
|
+
}
|
|
160
|
+
const reason = error instanceof GitOperationError ? 'commit-failed' : 'write-failed';
|
|
161
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
162
|
+
throw new MigrationError(reason, `Migration run failed: ${detail}`, { cause: error });
|
|
163
|
+
}
|
|
164
|
+
return toMigrate.map((file) => file.path);
|
|
165
|
+
}
|
|
166
|
+
// Resolves to the files migrated (their original paths).
|
|
167
|
+
export function runMigrations(config, themeSchemas, migrations, currentVersion, author, options = {}) {
|
|
168
|
+
return enqueue(() => runMigrationsJob(config, themeSchemas, migrations, currentVersion, author, options));
|
|
169
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { extname } from 'node:path';
|
|
2
|
+
// A small hand-rolled lookup, not a dependency (@fastify/static would
|
|
3
|
+
// be the obvious ecosystem package, but this is a handful of lines and
|
|
4
|
+
// matches this codebase's existing minimal-dependency posture). Only
|
|
5
|
+
// the common web asset types - nothing here needs to be exhaustive.
|
|
6
|
+
//
|
|
7
|
+
// Shared by both routes/assets.ts (theme CSS/JS/images/fonts) and
|
|
8
|
+
// routes/public.ts's theme/root/ mirror (robots.txt, .well-known/*,
|
|
9
|
+
// site-verification HTML files) via services/static-file.ts - the
|
|
10
|
+
// text/xml/html entries below exist specifically for that second,
|
|
11
|
+
// broader use case. Found live: robots.txt fell back to
|
|
12
|
+
// DEFAULT_MIME_TYPE (application/octet-stream), which every browser
|
|
13
|
+
// treats as "download this" rather than displaying it inline, even
|
|
14
|
+
// though the actual content is plain text.
|
|
15
|
+
export const MIME_TYPES = {
|
|
16
|
+
'.css': 'text/css; charset=utf-8',
|
|
17
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
18
|
+
'.json': 'application/json; charset=utf-8',
|
|
19
|
+
'.png': 'image/png',
|
|
20
|
+
'.jpg': 'image/jpeg',
|
|
21
|
+
'.jpeg': 'image/jpeg',
|
|
22
|
+
'.gif': 'image/gif',
|
|
23
|
+
'.svg': 'image/svg+xml',
|
|
24
|
+
'.webp': 'image/webp',
|
|
25
|
+
// Video: without these an uploaded .mp4 serves as the
|
|
26
|
+
// DEFAULT_MIME_TYPE below (application/octet-stream), which every
|
|
27
|
+
// browser treats as "download this" rather than playing it - and
|
|
28
|
+
// media-public.ts sets X-Content-Type-Options: nosniff, so nothing
|
|
29
|
+
// rescues it by sniffing. Same failure robots.txt already hit.
|
|
30
|
+
'.mp4': 'video/mp4',
|
|
31
|
+
'.webm': 'video/webm',
|
|
32
|
+
'.ico': 'image/x-icon',
|
|
33
|
+
'.woff': 'font/woff',
|
|
34
|
+
'.woff2': 'font/woff2',
|
|
35
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
36
|
+
'.xml': 'application/xml; charset=utf-8',
|
|
37
|
+
'.html': 'text/html; charset=utf-8',
|
|
38
|
+
'.htm': 'text/html; charset=utf-8',
|
|
39
|
+
};
|
|
40
|
+
export const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
|
41
|
+
export function mimeTypeFor(path) {
|
|
42
|
+
return MIME_TYPES[extname(path).toLowerCase()] ?? DEFAULT_MIME_TYPE;
|
|
43
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { SiteConfig } from '../config.ts';
|
|
2
|
+
import type { CommitAuthor } from './git.ts';
|
|
3
|
+
import type { PreparedOperation } from './prepared-operation.ts';
|
|
4
|
+
export type MoveReason = 'source-not-found' | 'destination-exists' | 'write-failed' | 'commit-failed' | 'rollback-failed';
|
|
5
|
+
export declare class MoveError extends Error {
|
|
6
|
+
readonly reason: MoveReason;
|
|
7
|
+
constructor(reason: MoveReason, message: string, options?: {
|
|
8
|
+
cause?: unknown;
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
export declare function prepareMovePage(config: SiteConfig, fromUrl: string, toUrl: string, options?: {
|
|
12
|
+
createRedirect?: boolean;
|
|
13
|
+
}): PreparedOperation;
|
|
14
|
+
export declare function movePage(config: SiteConfig, fromUrl: string, toUrl: string, message: string, author: CommitAuthor, options?: {
|
|
15
|
+
createRedirect?: boolean;
|
|
16
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { listFilesRecursively } from "./fs-walk.js";
|
|
4
|
+
import { commitPaths } from "./git.js";
|
|
5
|
+
import { sanitisePath } from "./path-safety.js";
|
|
6
|
+
import { linkSources, rewriteLinks } from "./links.js";
|
|
7
|
+
import { addRedirect, loadRedirects, removeRedirectForPath, serialiseRedirects } from "./redirects.js";
|
|
8
|
+
import { pagePathToUrl, urlToPagePath } from "./urls.js";
|
|
9
|
+
import { enqueue } from "./write-queue.js";
|
|
10
|
+
import { reindexInBackground } from "./reindex-on-write.js";
|
|
11
|
+
export class MoveError extends Error {
|
|
12
|
+
reason;
|
|
13
|
+
constructor(reason, message, options) {
|
|
14
|
+
super(message, options);
|
|
15
|
+
this.name = 'MoveError';
|
|
16
|
+
this.reason = reason;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// Validates and writes everything a move needs, stopping short of the
|
|
20
|
+
// commit itself - used both by the standalone movePageJob (which
|
|
21
|
+
// commits immediately after) and by batch.ts (which accumulates this
|
|
22
|
+
// alongside other operations and commits once for the whole batch). A
|
|
23
|
+
// write-phase failure is caught and rolled back here, inside prepare
|
|
24
|
+
// itself, so prepare either fully succeeds or fully undoes itself
|
|
25
|
+
// before throwing.
|
|
26
|
+
//
|
|
27
|
+
// options.createRedirect defaults true - existing callers (and
|
|
28
|
+
// batch.ts) keep today's behaviour unchanged. false skips only
|
|
29
|
+
// addRedirect (the "old URL now redirects to the new one" step);
|
|
30
|
+
// removeRedirectForPath still always runs regardless, since that's a
|
|
31
|
+
// correctness cleanup (the destination URL can't stay claimed by a
|
|
32
|
+
// stale redirect once a real page exists there), not a new redirect
|
|
33
|
+
// being created.
|
|
34
|
+
export function prepareMovePage(config, fromUrl, toUrl, options = {}) {
|
|
35
|
+
const createRedirect = options.createRedirect ?? true;
|
|
36
|
+
const fromRelative = urlToPagePath(fromUrl);
|
|
37
|
+
const toRelative = urlToPagePath(toUrl);
|
|
38
|
+
const fromPageFile = sanitisePath(config.pagesRoot, fromRelative);
|
|
39
|
+
const toPageFile = sanitisePath(config.pagesRoot, toRelative);
|
|
40
|
+
if (!existsSync(fromPageFile)) {
|
|
41
|
+
throw new MoveError('source-not-found', `No page found at "${fromUrl}"`);
|
|
42
|
+
}
|
|
43
|
+
if (existsSync(toPageFile)) {
|
|
44
|
+
throw new MoveError('destination-exists', `A page already exists at "${toUrl}"`);
|
|
45
|
+
}
|
|
46
|
+
const fromDirRelative = fromRelative.replace(/\.json$/, '');
|
|
47
|
+
const toDirRelative = toRelative.replace(/\.json$/, '');
|
|
48
|
+
const fromDir = sanitisePath(config.pagesRoot, fromDirRelative);
|
|
49
|
+
const toDir = sanitisePath(config.pagesRoot, toDirRelative);
|
|
50
|
+
const fromDirExists = existsSync(fromDir) && statSync(fromDir).isDirectory();
|
|
51
|
+
if (fromDirExists && existsSync(toDir)) {
|
|
52
|
+
throw new MoveError('destination-exists', `A directory already exists at the destination for "${toUrl}"`);
|
|
53
|
+
}
|
|
54
|
+
// A draft-only page (never published) already at the destination
|
|
55
|
+
// would be overwritten by this page's own draft moving there.
|
|
56
|
+
const hasDrafts = existsSync(config.draftsRoot);
|
|
57
|
+
if (hasDrafts && existsSync(sanitisePath(config.draftsRoot, join('pages', toRelative)))) {
|
|
58
|
+
throw new MoveError('destination-exists', `A draft page already exists at "${toUrl}"`);
|
|
59
|
+
}
|
|
60
|
+
// Enumerate every affected page (the page itself, plus every
|
|
61
|
+
// descendant if it has any) before touching disk. This list drives
|
|
62
|
+
// both the redirect entries (E3: one per affected page) and the git
|
|
63
|
+
// staging list.
|
|
64
|
+
const affected = [
|
|
65
|
+
{ oldRelativePath: fromRelative, newRelativePath: toRelative, oldUrl: fromUrl, newUrl: toUrl },
|
|
66
|
+
];
|
|
67
|
+
if (fromDirExists) {
|
|
68
|
+
for (const rel of listFilesRecursively(fromDir, fromDir, '.json')) {
|
|
69
|
+
const oldRelativePath = join(fromDirRelative, rel);
|
|
70
|
+
const newRelativePath = join(toDirRelative, rel);
|
|
71
|
+
affected.push({
|
|
72
|
+
oldRelativePath,
|
|
73
|
+
newRelativePath,
|
|
74
|
+
oldUrl: pagePathToUrl(oldRelativePath),
|
|
75
|
+
newUrl: pagePathToUrl(newRelativePath),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const performedRenames = [];
|
|
80
|
+
// Files whose links were rewritten, with what they held before.
|
|
81
|
+
const rewrittenFiles = [];
|
|
82
|
+
let redirectsBefore = null;
|
|
83
|
+
let redirectsChanged = false;
|
|
84
|
+
// Reverses every rename (in reverse order) and restores/removes
|
|
85
|
+
// redirects.json. Attempts everything even if one step fails,
|
|
86
|
+
// reporting every failure - matches publish.ts's rollback()
|
|
87
|
+
// philosophy exactly (a filesystem cannot give a true atomicity
|
|
88
|
+
// guarantee, so the honest contract is "restores everything it can,
|
|
89
|
+
// and is loud if it can't").
|
|
90
|
+
function undoMoveWrites() {
|
|
91
|
+
const restoreFailures = [];
|
|
92
|
+
// Links first: they were rewritten after the renames, in the files'
|
|
93
|
+
// new places.
|
|
94
|
+
for (const rewritten of [...rewrittenFiles].reverse()) {
|
|
95
|
+
try {
|
|
96
|
+
writeFileSync(rewritten.file, rewritten.before);
|
|
97
|
+
}
|
|
98
|
+
catch (restoreError) {
|
|
99
|
+
restoreFailures.push(restoreError);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const rename of [...performedRenames].reverse()) {
|
|
103
|
+
try {
|
|
104
|
+
renameSync(rename.to, rename.from);
|
|
105
|
+
}
|
|
106
|
+
catch (restoreError) {
|
|
107
|
+
restoreFailures.push(restoreError);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (redirectsChanged) {
|
|
111
|
+
try {
|
|
112
|
+
if (redirectsBefore === null) {
|
|
113
|
+
rmSync(config.redirectsPath, { force: true });
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
writeFileSync(config.redirectsPath, redirectsBefore);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch (restoreError) {
|
|
120
|
+
restoreFailures.push(restoreError);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return restoreFailures;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
mkdirSync(dirname(toPageFile), { recursive: true });
|
|
127
|
+
renameSync(fromPageFile, toPageFile);
|
|
128
|
+
performedRenames.push({ from: fromPageFile, to: toPageFile });
|
|
129
|
+
if (fromDirExists) {
|
|
130
|
+
mkdirSync(dirname(toDir), { recursive: true });
|
|
131
|
+
renameSync(fromDir, toDir);
|
|
132
|
+
performedRenames.push({ from: fromDir, to: toDir });
|
|
133
|
+
}
|
|
134
|
+
// Each affected page's draft moves with it: left behind, it became
|
|
135
|
+
// a draft-only page at the old address (found while adding link
|
|
136
|
+
// rewriting). Drafts aren't committed, so they're renamed but never
|
|
137
|
+
// staged; undo still puts them back.
|
|
138
|
+
for (const page of hasDrafts ? affected : []) {
|
|
139
|
+
const draftFrom = sanitisePath(config.draftsRoot, join('pages', page.oldRelativePath));
|
|
140
|
+
if (existsSync(draftFrom)) {
|
|
141
|
+
const draftTo = sanitisePath(config.draftsRoot, join('pages', page.newRelativePath));
|
|
142
|
+
mkdirSync(dirname(draftTo), { recursive: true });
|
|
143
|
+
renameSync(draftFrom, draftTo);
|
|
144
|
+
performedRenames.push({ from: draftFrom, to: draftTo });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
redirectsBefore = existsSync(config.redirectsPath)
|
|
148
|
+
? readFileSync(config.redirectsPath, 'utf-8')
|
|
149
|
+
: null;
|
|
150
|
+
let entries = loadRedirects(config).entries;
|
|
151
|
+
for (const page of affected) {
|
|
152
|
+
// E5 (move half) must run BEFORE addRedirect, not after: if the
|
|
153
|
+
// destination held a stale redirect (page.newUrl was itself a
|
|
154
|
+
// key), addRedirect's chain-walk would otherwise treat it as a
|
|
155
|
+
// legitimate hop and silently collapse straight through it,
|
|
156
|
+
// rather than the new page superseding it.
|
|
157
|
+
entries = removeRedirectForPath(entries, page.newUrl);
|
|
158
|
+
if (createRedirect) {
|
|
159
|
+
entries = addRedirect(entries, page.oldUrl, page.newUrl).entries;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const redirectsAfter = serialiseRedirects(entries);
|
|
163
|
+
if (redirectsAfter !== (redirectsBefore ?? '')) {
|
|
164
|
+
writeFileSync(config.redirectsPath, redirectsAfter);
|
|
165
|
+
redirectsChanged = true;
|
|
166
|
+
}
|
|
167
|
+
// Every link to the moved page (or a page under it) now points at
|
|
168
|
+
// its new address - in live pages, drafts, menus and the site
|
|
169
|
+
// settings - in this same commit, so links never lean on the
|
|
170
|
+
// redirect above. The redirect stays for links from elsewhere.
|
|
171
|
+
for (const source of linkSources(config)) {
|
|
172
|
+
const before = readFileSync(source.file, 'utf-8');
|
|
173
|
+
let parsed;
|
|
174
|
+
try {
|
|
175
|
+
parsed = JSON.parse(before);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const rewritten = rewriteLinks(parsed, fromUrl, toUrl);
|
|
181
|
+
if (!rewritten.changed) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
writeFileSync(source.file, `${JSON.stringify(rewritten.value, null, 2)}${before.endsWith('\n') ? '\n' : ''}`);
|
|
185
|
+
rewrittenFiles.push({ file: source.file, before, commit: source.kind !== 'draft' });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
// A write-phase failure: roll back immediately, inside prepare
|
|
190
|
+
// itself, and always classify as write-failed - commitPaths hasn't
|
|
191
|
+
// been called yet, so this can never be a GitOperationError.
|
|
192
|
+
const failures = undoMoveWrites();
|
|
193
|
+
if (failures.length > 0) {
|
|
194
|
+
throw new MoveError('rollback-failed', 'Move failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause: error });
|
|
195
|
+
}
|
|
196
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
197
|
+
throw new MoveError('write-failed', `Move failed: ${detail}`, { cause: error });
|
|
198
|
+
}
|
|
199
|
+
const paths = [];
|
|
200
|
+
for (const page of affected) {
|
|
201
|
+
paths.push(sanitisePath(config.pagesRoot, page.oldRelativePath));
|
|
202
|
+
paths.push(sanitisePath(config.pagesRoot, page.newRelativePath));
|
|
203
|
+
}
|
|
204
|
+
if (redirectsChanged) {
|
|
205
|
+
paths.push(config.redirectsPath);
|
|
206
|
+
}
|
|
207
|
+
// Drafts are never committed; everything else rewritten is.
|
|
208
|
+
for (const rewritten of rewrittenFiles) {
|
|
209
|
+
if (rewritten.commit && !paths.includes(rewritten.file)) {
|
|
210
|
+
paths.push(rewritten.file);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return { paths, undo: undoMoveWrites };
|
|
214
|
+
}
|
|
215
|
+
async function movePageJob(config, fromUrl, toUrl, message, author, options = {}) {
|
|
216
|
+
const { paths, undo } = prepareMovePage(config, fromUrl, toUrl, options);
|
|
217
|
+
try {
|
|
218
|
+
commitPaths(config.siteRoot, paths, message, author);
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
const failures = undo();
|
|
222
|
+
if (failures.length > 0) {
|
|
223
|
+
throw new MoveError('rollback-failed', 'Move failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause: error });
|
|
224
|
+
}
|
|
225
|
+
// Always commit-failed here, structurally: prepare already
|
|
226
|
+
// succeeded, so anything caught in this try can only have come
|
|
227
|
+
// from commitPaths itself.
|
|
228
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
229
|
+
throw new MoveError('commit-failed', `Move failed: ${detail}`, { cause: error });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
export function movePage(config, fromUrl, toUrl, message, author, options = {}) {
|
|
233
|
+
const result = enqueue(() => movePageJob(config, fromUrl, toUrl, message, author, options));
|
|
234
|
+
result.then(() => reindexInBackground(config), () => undefined);
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type PathSafetyReason = 'absolute-path' | 'traversal-segment' | 'malformed-encoding' | 'outside-root' | 'resolves-to-root' | 'symlink-escape' | 'invalid-path';
|
|
2
|
+
export declare class PathSafetyError extends Error {
|
|
3
|
+
readonly reason: PathSafetyReason;
|
|
4
|
+
constructor(reason: PathSafetyReason, message: string);
|
|
5
|
+
}
|
|
6
|
+
export declare function sanitisePath(root: string, requestedPath: string): string;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { dirname, isAbsolute, resolve, sep } from 'node:path';
|
|
3
|
+
export class PathSafetyError extends Error {
|
|
4
|
+
reason;
|
|
5
|
+
constructor(reason, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'PathSafetyError';
|
|
8
|
+
this.reason = reason;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function isWithin(root, candidate) {
|
|
12
|
+
return candidate === root || candidate.startsWith(root + sep);
|
|
13
|
+
}
|
|
14
|
+
// Only existing path segments can be symlinks, so for a target that
|
|
15
|
+
// doesn't exist yet (e.g. a new draft being written for the first time)
|
|
16
|
+
// it is sufficient to realpath the nearest existing ancestor and confirm
|
|
17
|
+
// that stays within the root; segments that don't exist can't smuggle a
|
|
18
|
+
// symlink escape.
|
|
19
|
+
function realpathOfNearestExisting(candidate) {
|
|
20
|
+
let current = candidate;
|
|
21
|
+
for (;;) {
|
|
22
|
+
try {
|
|
23
|
+
return realpathSync(current);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
const code = error.code;
|
|
27
|
+
// ENOENT/ENOTDIR mean only "this segment doesn't exist yet" -
|
|
28
|
+
// walk up to the nearest ancestor that does, per this function's
|
|
29
|
+
// own doc comment. Any other failure (ENAMETOOLONG for an
|
|
30
|
+
// over-length segment, ERR_INVALID_ARG_VALUE for an embedded null
|
|
31
|
+
// byte, etc. - verified empirically, not an exhaustive enum by
|
|
32
|
+
// design) means the path itself is malformed, not merely absent.
|
|
33
|
+
// Rejecting it here as a PathSafetyError is required, not
|
|
34
|
+
// optional: left as a raw rethrow, it would escape every route
|
|
35
|
+
// handler's `instanceof PathSafetyError` check and surface as an
|
|
36
|
+
// unsanitised 500, the exact failure mode this file exists to
|
|
37
|
+
// prevent (CLAUDE.md's #1 security concern).
|
|
38
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
|
|
39
|
+
throw new PathSafetyError('invalid-path', `Path could not be resolved: "${candidate}"`);
|
|
40
|
+
}
|
|
41
|
+
const parent = dirname(current);
|
|
42
|
+
if (parent === current) {
|
|
43
|
+
throw new PathSafetyError('invalid-path', `Path could not be resolved: "${candidate}"`);
|
|
44
|
+
}
|
|
45
|
+
current = parent;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// The single shared path-sanitisation helper. Every fs-touching code
|
|
50
|
+
// path that handles a request-supplied :path parameter must resolve it
|
|
51
|
+
// through this function before touching disk (enforced by a grep test,
|
|
52
|
+
// see test/static/static-analysis.test.ts).
|
|
53
|
+
export function sanitisePath(root, requestedPath) {
|
|
54
|
+
const normalisedRoot = resolve(root);
|
|
55
|
+
// Step 1: reject a raw absolute path before any decoding.
|
|
56
|
+
if (isAbsolute(requestedPath)) {
|
|
57
|
+
throw new PathSafetyError('absolute-path', `Path must be relative, got "${requestedPath}"`);
|
|
58
|
+
}
|
|
59
|
+
// Step 2: percent-decode. Decoding before splitting into segments (not
|
|
60
|
+
// decoding each segment independently) is what's actually required to
|
|
61
|
+
// catch an encoded slash disguising a segment boundary, e.g.
|
|
62
|
+
// "a%2f..%2fetc" decodes to "a/../etc" and is then caught by the
|
|
63
|
+
// segment scan below; decoding per-segment would miss it. Malformed
|
|
64
|
+
// encoding is rejected outright rather than passed through: this will
|
|
65
|
+
// also reject a benign filename containing a literal unencoded "%",
|
|
66
|
+
// which is an acceptable reject-on-ambiguity trade-off, not a bug.
|
|
67
|
+
let decoded;
|
|
68
|
+
try {
|
|
69
|
+
decoded = decodeURIComponent(requestedPath);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
throw new PathSafetyError('malformed-encoding', `Path contains malformed percent-encoding: "${requestedPath}"`);
|
|
73
|
+
}
|
|
74
|
+
// Step 3: an encoded absolute path (e.g. "%2fetc%2fpasswd") doesn't
|
|
75
|
+
// look absolute in raw form, so check again after decoding.
|
|
76
|
+
if (isAbsolute(decoded)) {
|
|
77
|
+
throw new PathSafetyError('absolute-path', `Path must be relative, got "${requestedPath}"`);
|
|
78
|
+
}
|
|
79
|
+
// Step 4: reject any ".." segment, covering both plain and encoded
|
|
80
|
+
// traversal now that decoding has already happened.
|
|
81
|
+
if (decoded.split('/').includes('..')) {
|
|
82
|
+
throw new PathSafetyError('traversal-segment', `Path must not contain ".." segments: "${requestedPath}"`);
|
|
83
|
+
}
|
|
84
|
+
// Step 5: string-only containment check, still no fs call.
|
|
85
|
+
const resolved = resolve(normalisedRoot, decoded);
|
|
86
|
+
if (!isWithin(normalisedRoot, resolved)) {
|
|
87
|
+
throw new PathSafetyError('outside-root', `Path resolves outside the allowed root: "${requestedPath}"`);
|
|
88
|
+
}
|
|
89
|
+
// Step 6: deliberate hardening beyond the literal B3-B6 wording. A
|
|
90
|
+
// request that resolves to exactly the root (e.g. "", ".") must not be
|
|
91
|
+
// handed to a caller expecting a single file path.
|
|
92
|
+
if (resolved === normalisedRoot) {
|
|
93
|
+
throw new PathSafetyError('resolves-to-root', `Path must not resolve to the root itself: "${requestedPath}"`);
|
|
94
|
+
}
|
|
95
|
+
// Step 7: only now touch the filesystem, to catch a symlink inside the
|
|
96
|
+
// tree pointing outside the root.
|
|
97
|
+
const realRoot = realpathSync(normalisedRoot);
|
|
98
|
+
const realResolved = realpathOfNearestExisting(resolved);
|
|
99
|
+
if (!isWithin(realRoot, realResolved)) {
|
|
100
|
+
throw new PathSafetyError('symlink-escape', `Path escapes the allowed root via a symlink: "${requestedPath}"`);
|
|
101
|
+
}
|
|
102
|
+
// Accepted residual risk, not a structural gap: the symlink check
|
|
103
|
+
// above and the fs operation a caller performs with the returned
|
|
104
|
+
// path are not atomic, so a symlink swapped in between could in
|
|
105
|
+
// principle slip through (TOCTOU). Given the actual threat model -
|
|
106
|
+
// a single-operator site, all writes serialised through one queue,
|
|
107
|
+
// no concurrent untrusted filesystem access - this is low realistic
|
|
108
|
+
// risk. Flagged by plan-reviewer's Phase 1 audit; noted rather than
|
|
109
|
+
// solved, the same way other accepted trade-offs in this codebase
|
|
110
|
+
// are (see publish.ts's rollback() comment).
|
|
111
|
+
return resolved;
|
|
112
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SiteConfig } from '../config.ts';
|
|
2
|
+
export interface PidRecord {
|
|
3
|
+
pid: number;
|
|
4
|
+
serverPid: number;
|
|
5
|
+
port: number;
|
|
6
|
+
startedAt: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function pidFilePath(config: SiteConfig): string;
|
|
9
|
+
export declare function writePidFile(config: SiteConfig, port: number): void;
|
|
10
|
+
export declare function readPidFile(config: SiteConfig): PidRecord | null;
|
|
11
|
+
export declare function removeOwnPidFile(config: SiteConfig): void;
|
|
12
|
+
export declare function removePidFile(config: SiteConfig): void;
|
|
13
|
+
export declare function isProcessAlive(pid: number): boolean;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
export function pidFilePath(config) {
|
|
4
|
+
return join(config.dataRoot, 'server.pid');
|
|
5
|
+
}
|
|
6
|
+
// Node sets WATCH_REPORT_DEPENDENCIES in the child it runs under
|
|
7
|
+
// --watch, whose parent is then the watcher itself.
|
|
8
|
+
function stopTarget() {
|
|
9
|
+
return process.env.WATCH_REPORT_DEPENDENCIES ? process.ppid : process.pid;
|
|
10
|
+
}
|
|
11
|
+
export function writePidFile(config, port) {
|
|
12
|
+
const record = { pid: stopTarget(), serverPid: process.pid, port, startedAt: new Date().toISOString() };
|
|
13
|
+
mkdirSync(config.dataRoot, { recursive: true });
|
|
14
|
+
writeFileSync(pidFilePath(config), `${JSON.stringify(record, null, 2)}\n`);
|
|
15
|
+
}
|
|
16
|
+
export function readPidFile(config) {
|
|
17
|
+
try {
|
|
18
|
+
const parsed = JSON.parse(readFileSync(pidFilePath(config), 'utf-8'));
|
|
19
|
+
const { pid, serverPid, port, startedAt } = parsed;
|
|
20
|
+
if (typeof pid !== 'number' ||
|
|
21
|
+
typeof serverPid !== 'number' ||
|
|
22
|
+
typeof port !== 'number' ||
|
|
23
|
+
typeof startedAt !== 'string') {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return { pid, serverPid, port, startedAt };
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// Only removes the file if it is still this process's own record: under
|
|
33
|
+
// `npm run dev` the watcher starts a new process on a theme change, and
|
|
34
|
+
// the old one shutting down must not delete the record the new one has
|
|
35
|
+
// just written.
|
|
36
|
+
export function removeOwnPidFile(config) {
|
|
37
|
+
if (readPidFile(config)?.serverPid === process.pid) {
|
|
38
|
+
removePidFile(config);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function removePidFile(config) {
|
|
42
|
+
rmSync(pidFilePath(config), { force: true });
|
|
43
|
+
}
|
|
44
|
+
// Whether a process with this id exists. EPERM means it does, but
|
|
45
|
+
// belongs to another user.
|
|
46
|
+
export function isProcessAlive(pid) {
|
|
47
|
+
try {
|
|
48
|
+
process.kill(pid, 0);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
return error instanceof Error && 'code' in error && error.code === 'EPERM';
|
|
53
|
+
}
|
|
54
|
+
}
|