@velastack/cms 0.1.0 → 0.2.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 +1 -1
- package/dist/backend/auth/editors.d.ts +51 -0
- package/dist/backend/auth/editors.js +157 -0
- package/dist/backend/auth/local-editors.d.ts +22 -0
- package/dist/backend/auth/local-editors.js +44 -0
- package/dist/backend/auth/scrypt.d.ts +20 -0
- package/dist/backend/auth/scrypt.js +80 -0
- package/dist/backend/factory.d.ts +25 -0
- package/dist/backend/factory.js +202 -0
- package/dist/backend/http.d.ts +34 -0
- package/dist/backend/http.js +79 -0
- package/dist/backend/index.d.ts +45 -0
- package/dist/backend/index.js +39 -0
- package/dist/backend/router.d.ts +52 -0
- package/dist/backend/router.js +57 -0
- package/dist/backend/routes/context.d.ts +46 -0
- package/dist/backend/routes/context.js +30 -0
- package/dist/backend/routes/docs.d.ts +2 -0
- package/dist/backend/routes/docs.js +150 -0
- package/dist/backend/routes/login.d.ts +19 -0
- package/dist/backend/routes/login.js +155 -0
- package/dist/backend/routes/media.d.ts +22 -0
- package/dist/backend/routes/media.js +119 -0
- package/dist/backend/routes/pages.d.ts +19 -0
- package/dist/backend/routes/pages.js +221 -0
- package/dist/backend/routes/release.d.ts +10 -0
- package/dist/backend/routes/release.js +147 -0
- package/dist/backend/routes/site.d.ts +2 -0
- package/dist/backend/routes/site.js +40 -0
- package/dist/backend/routes/table.d.ts +11 -0
- package/dist/backend/routes/table.js +72 -0
- package/dist/backend/routes/test-reset.d.ts +15 -0
- package/dist/backend/routes/test-reset.js +112 -0
- package/dist/backend/routes/user.d.ts +14 -0
- package/dist/backend/routes/user.js +21 -0
- package/dist/backend/storage.d.ts +18 -0
- package/dist/backend/storage.js +45 -0
- package/dist/backend/store/cache.d.ts +34 -0
- package/dist/backend/store/cache.js +38 -0
- package/dist/backend/store/db.d.ts +10 -0
- package/dist/backend/store/db.js +29 -0
- package/dist/backend/store/migrate.d.ts +10 -0
- package/dist/backend/store/migrate.js +30 -0
- package/dist/backend/store/migrations/0001-init.d.ts +1 -0
- package/dist/backend/store/migrations/0001-init.js +129 -0
- package/dist/backend/store/migrations/0002-site.d.ts +1 -0
- package/dist/backend/store/migrations/0002-site.js +86 -0
- package/dist/backend/store/migrations/0003-editors.d.ts +1 -0
- package/dist/backend/store/migrations/0003-editors.js +44 -0
- package/dist/backend/store/migrations/index.d.ts +15 -0
- package/dist/backend/store/migrations/index.js +18 -0
- package/dist/backend/store/queries.d.ts +67 -0
- package/dist/backend/store/queries.js +1207 -0
- package/dist/backend/store/sqlite.d.ts +22 -0
- package/dist/backend/store/sqlite.js +29 -0
- package/dist/backend/testing/fixtures.d.ts +39 -0
- package/dist/backend/testing/fixtures.js +61 -0
- package/dist/backend/testing/harness.d.ts +35 -0
- package/dist/backend/testing/harness.js +149 -0
- package/dist/backend/types.d.ts +102 -0
- package/dist/cms.d.ts +3 -3
- package/dist/cms.js +1 -1
- package/dist/components/admin-bar/publish-dialog.svelte +1 -1
- package/dist/components/cms/cms-image-editable.svelte +1 -1
- package/dist/components/cms/cms-store.svelte.d.ts +3 -68
- package/dist/components/cms/cms-store.svelte.js +25 -10
- package/dist/components/cms/locale-merge.d.ts +1 -1
- package/dist/components/cms/locale-merge.js +1 -1
- package/dist/components/cms/scope.d.ts +1 -1
- package/dist/core/page-entry.d.ts +43 -0
- package/dist/core/page-entry.js +21 -0
- package/dist/{components/cms → core}/path.d.ts +13 -0
- package/dist/{components/cms → core}/path.js +36 -0
- package/dist/core/wire.d.ts +182 -0
- package/dist/core/wire.js +1 -0
- package/dist/{srv → server}/api-adapter.js +2 -2
- package/dist/{srv → server}/load-cms.js +1 -1
- package/dist/{srv → server}/mock-adapter.d.ts +3 -24
- package/dist/{srv → server}/mock-adapter.js +5 -22
- package/dist/{srv → server}/types.d.ts +2 -6
- package/dist/server/types.js +1 -0
- package/package.json +13 -4
- package/plugin/dist/index.js +7 -0
- package/plugin/dist/index.js.map +1 -1
- package/plugin/dist/media.d.ts +16 -7
- package/plugin/dist/media.js +23 -10
- package/plugin/dist/media.js.map +1 -1
- /package/dist/{srv → backend}/types.js +0 -0
- /package/dist/{srv → server}/README.md +0 -0
- /package/dist/{srv → server}/api-adapter.d.ts +0 -0
- /package/dist/{srv → server}/cms.d.ts +0 -0
- /package/dist/{srv → server}/cms.js +0 -0
- /package/dist/{srv → server}/index.d.ts +0 -0
- /package/dist/{srv → server}/index.js +0 -0
- /package/dist/{srv → server}/load-cms.d.ts +0 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { badRequest, isObjectRecord, isStringRecord, readJson, requireUser } from './context.js';
|
|
2
|
+
const parseItems = (raw) => {
|
|
3
|
+
if (!Array.isArray(raw))
|
|
4
|
+
return 'items must be an array';
|
|
5
|
+
const out = [];
|
|
6
|
+
for (const r of raw) {
|
|
7
|
+
if (!isObjectRecord(r))
|
|
8
|
+
return 'each item must be an object';
|
|
9
|
+
const { kind, routeId, locale, params, tree } = r;
|
|
10
|
+
if (kind !== 'page' && kind !== 'layout' && kind !== 'site') {
|
|
11
|
+
return 'item.kind must be "page", "layout", or "site"';
|
|
12
|
+
}
|
|
13
|
+
if (!isObjectRecord(tree))
|
|
14
|
+
return 'item.tree must be an object';
|
|
15
|
+
if (kind === 'site') {
|
|
16
|
+
out.push({ kind: 'site', tree });
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (typeof routeId !== 'string' || routeId === '')
|
|
20
|
+
return 'item.routeId required';
|
|
21
|
+
if (typeof locale !== 'string' || locale === '')
|
|
22
|
+
return 'item.locale required';
|
|
23
|
+
if (kind === 'page') {
|
|
24
|
+
if (!isStringRecord(params))
|
|
25
|
+
return 'page item.params must be a string-keyed object';
|
|
26
|
+
out.push({ kind: 'page', routeId, locale, params, tree });
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
out.push({ kind: 'layout', routeId, locale, tree });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
};
|
|
34
|
+
const parseTarget = (raw) => {
|
|
35
|
+
if (!isObjectRecord(raw))
|
|
36
|
+
return 'object body required';
|
|
37
|
+
const { kind, routeId, locale, params } = raw;
|
|
38
|
+
if (kind === 'site')
|
|
39
|
+
return { kind: 'site' };
|
|
40
|
+
if (kind !== 'page' && kind !== 'layout' && kind !== 'page-delete') {
|
|
41
|
+
return 'kind must be "page", "layout", "page-delete", or "site"';
|
|
42
|
+
}
|
|
43
|
+
if (typeof routeId !== 'string' || routeId === '')
|
|
44
|
+
return 'routeId required';
|
|
45
|
+
if (typeof locale !== 'string' || locale === '')
|
|
46
|
+
return 'locale required';
|
|
47
|
+
if (kind === 'layout')
|
|
48
|
+
return { kind: 'layout', routeId, locale };
|
|
49
|
+
if (!isStringRecord(params))
|
|
50
|
+
return `${kind} params must be a string-keyed object`;
|
|
51
|
+
return { kind, routeId, locale, params };
|
|
52
|
+
};
|
|
53
|
+
export const getRelease = (ctx) => {
|
|
54
|
+
const user = requireUser(ctx);
|
|
55
|
+
const release = ctx.store.getOpenRelease(ctx.projectId, user.id);
|
|
56
|
+
return Response.json({ release: release ?? null });
|
|
57
|
+
};
|
|
58
|
+
export const postReleaseItems = async (ctx) => {
|
|
59
|
+
const user = requireUser(ctx);
|
|
60
|
+
const body = await readJson(ctx.event);
|
|
61
|
+
if (body === undefined)
|
|
62
|
+
return badRequest('invalid json');
|
|
63
|
+
if (!isObjectRecord(body))
|
|
64
|
+
return badRequest('object body required');
|
|
65
|
+
const items = parseItems(body.items);
|
|
66
|
+
if (typeof items === 'string')
|
|
67
|
+
return badRequest(items);
|
|
68
|
+
const release = ctx.store.addReleaseItems(ctx.projectId, user.id, items);
|
|
69
|
+
return Response.json({ release });
|
|
70
|
+
};
|
|
71
|
+
export const deleteReleaseItems = async (ctx) => {
|
|
72
|
+
const user = requireUser(ctx);
|
|
73
|
+
const body = await readJson(ctx.event);
|
|
74
|
+
if (body === undefined)
|
|
75
|
+
return badRequest('invalid json');
|
|
76
|
+
const target = parseTarget(body);
|
|
77
|
+
if (typeof target === 'string')
|
|
78
|
+
return badRequest(target);
|
|
79
|
+
const ok = ctx.store.discardReleaseItem(ctx.projectId, user.id, target);
|
|
80
|
+
if (!ok)
|
|
81
|
+
return new Response(null, { status: 404 });
|
|
82
|
+
return Response.json({ ok: true });
|
|
83
|
+
};
|
|
84
|
+
export const postReleasePublish = async (ctx) => {
|
|
85
|
+
const user = requireUser(ctx);
|
|
86
|
+
let name;
|
|
87
|
+
const text = await ctx.event.request.text();
|
|
88
|
+
if (text) {
|
|
89
|
+
let body;
|
|
90
|
+
try {
|
|
91
|
+
body = JSON.parse(text);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return badRequest('invalid json');
|
|
95
|
+
}
|
|
96
|
+
if (!isObjectRecord(body))
|
|
97
|
+
return badRequest('object body required');
|
|
98
|
+
if (body.name != null) {
|
|
99
|
+
if (typeof body.name !== 'string')
|
|
100
|
+
return badRequest('name must be a string');
|
|
101
|
+
name = body.name;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const published = ctx.store.publishRelease(ctx.projectId, user.id, name);
|
|
105
|
+
if (!published)
|
|
106
|
+
return new Response('no open release to publish', { status: 404 });
|
|
107
|
+
return Response.json({ release: published });
|
|
108
|
+
};
|
|
109
|
+
export const postReleaseDiscard = (ctx) => {
|
|
110
|
+
const user = requireUser(ctx);
|
|
111
|
+
return Response.json({ ok: ctx.store.discardOpenRelease(ctx.projectId, user.id) });
|
|
112
|
+
};
|
|
113
|
+
export const postReleasePreviewKey = (ctx) => {
|
|
114
|
+
const user = requireUser(ctx);
|
|
115
|
+
const key = ctx.store.regeneratePreviewKey(ctx.projectId, user.id);
|
|
116
|
+
if (!key)
|
|
117
|
+
return new Response('no open release', { status: 404 });
|
|
118
|
+
return Response.json({ preview_key: key });
|
|
119
|
+
};
|
|
120
|
+
export const getReleaseHistory = (ctx) => {
|
|
121
|
+
requireUser(ctx);
|
|
122
|
+
const history = ctx.store
|
|
123
|
+
.getReleaseHistory(ctx.projectId)
|
|
124
|
+
.slice()
|
|
125
|
+
.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1));
|
|
126
|
+
return Response.json({ history });
|
|
127
|
+
};
|
|
128
|
+
export const postReleaseRevert = (ctx) => {
|
|
129
|
+
const user = requireUser(ctx);
|
|
130
|
+
const id = ctx.params.id;
|
|
131
|
+
if (!id)
|
|
132
|
+
return badRequest('id required');
|
|
133
|
+
const reverted = ctx.store.revertRelease(ctx.projectId, id, user.id);
|
|
134
|
+
if (!reverted)
|
|
135
|
+
return new Response('release not found', { status: 404 });
|
|
136
|
+
return Response.json({ release: reverted });
|
|
137
|
+
};
|
|
138
|
+
export const postReleaseHistoryPreviewKey = (ctx) => {
|
|
139
|
+
requireUser(ctx);
|
|
140
|
+
const id = ctx.params.id;
|
|
141
|
+
if (!id)
|
|
142
|
+
return badRequest('release id required');
|
|
143
|
+
const key = ctx.store.regeneratePublishedPreviewKey(ctx.projectId, id);
|
|
144
|
+
if (!key)
|
|
145
|
+
return new Response('release not found', { status: 404 });
|
|
146
|
+
return Response.json({ preview_key: key });
|
|
147
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /site` — project-wide settings (branding, contact, social).
|
|
3
|
+
*
|
|
4
|
+
* One tree per project: no locale, no params. Mirrors `/docs` for the cache,
|
|
5
|
+
* preview, and version contract, and always returns 200 (an unset site is an
|
|
6
|
+
* empty tree, not a 404).
|
|
7
|
+
*/
|
|
8
|
+
import { mergeTree } from '../../core/path.js';
|
|
9
|
+
export const getSite = (ctx) => {
|
|
10
|
+
const { event, projectId, store, respond } = ctx;
|
|
11
|
+
const previewKey = event.url.searchParams.get('preview');
|
|
12
|
+
const versionKey = event.url.searchParams.get('version');
|
|
13
|
+
if (versionKey) {
|
|
14
|
+
const release = store.findPublishedReleaseByPreviewKey(projectId, versionKey);
|
|
15
|
+
if (!release)
|
|
16
|
+
return new Response('release not found', { status: 404 });
|
|
17
|
+
const snap = store.buildStateAtRelease(projectId, release.id);
|
|
18
|
+
if (!snap)
|
|
19
|
+
return new Response('release not found', { status: 404 });
|
|
20
|
+
return respond.serveImmutableJson(event, { contents: snap.siteSnap });
|
|
21
|
+
}
|
|
22
|
+
if (previewKey) {
|
|
23
|
+
const base = store.getPublishedSiteTree(projectId);
|
|
24
|
+
const release = store.findOpenReleaseByPreviewKey(projectId, previewKey);
|
|
25
|
+
let contents = { ...base };
|
|
26
|
+
if (release) {
|
|
27
|
+
for (const item of release.items) {
|
|
28
|
+
if (item.kind !== 'site')
|
|
29
|
+
continue;
|
|
30
|
+
contents = mergeTree(contents, item.tree);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return Response.json({ contents });
|
|
34
|
+
}
|
|
35
|
+
const cacheCheck = respond.tryPublicCache(event, projectId, 'site');
|
|
36
|
+
if (cacheCheck.hit)
|
|
37
|
+
return cacheCheck.response;
|
|
38
|
+
const base = store.getPublishedSiteTree(projectId);
|
|
39
|
+
return respond.servePublicJson(event, projectId, 'site', cacheCheck, { contents: base });
|
|
40
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The route table.
|
|
3
|
+
*
|
|
4
|
+
* `auth` semantics:
|
|
5
|
+
* - `public` anonymous callers are served published content
|
|
6
|
+
* - `required` no editor is a 403
|
|
7
|
+
* - `exempt` skips the cross-project check, so an editor holding a session for
|
|
8
|
+
* one project can still reach another's login form and log out
|
|
9
|
+
*/
|
|
10
|
+
import type { Route } from '../router.js';
|
|
11
|
+
export declare const ROUTES: Route[];
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { getDocs } from './docs.js';
|
|
2
|
+
import { getSite } from './site.js';
|
|
3
|
+
import { deletePages, getPages, postPages, postPagesRename } from './pages.js';
|
|
4
|
+
import { getUser, postLogout } from './user.js';
|
|
5
|
+
import { getLogin, getLoginSuccess, postLogin } from './login.js';
|
|
6
|
+
import { deleteReleaseItems, getRelease, getReleaseHistory, postReleaseDiscard, postReleaseHistoryPreviewKey, postReleaseItems, postReleasePreviewKey, postReleasePublish, postReleaseRevert } from './release.js';
|
|
7
|
+
import { deleteMediaItem, getMedia, getUpload, postMedia } from './media.js';
|
|
8
|
+
import { postTestReset } from './test-reset.js';
|
|
9
|
+
export const ROUTES = [
|
|
10
|
+
{ method: 'GET', segments: ['docs'], auth: 'public', handler: getDocs },
|
|
11
|
+
{ method: 'GET', segments: ['site'], auth: 'public', handler: getSite },
|
|
12
|
+
{ method: 'GET', segments: ['pages'], auth: 'public', handler: getPages },
|
|
13
|
+
{ method: 'POST', segments: ['pages'], auth: 'required', handler: postPages },
|
|
14
|
+
{ method: 'DELETE', segments: ['pages'], auth: 'required', handler: deletePages },
|
|
15
|
+
{ method: 'POST', segments: ['pages', 'rename'], auth: 'required', handler: postPagesRename },
|
|
16
|
+
{ method: 'GET', segments: ['user'], auth: 'required', handler: getUser },
|
|
17
|
+
{ method: 'POST', segments: ['logout'], auth: 'exempt', handler: postLogout },
|
|
18
|
+
{ method: 'GET', segments: ['iframe', 'login'], auth: 'exempt', handler: getLogin },
|
|
19
|
+
{ method: 'POST', segments: ['iframe', 'login'], auth: 'exempt', handler: postLogin },
|
|
20
|
+
{
|
|
21
|
+
method: 'GET',
|
|
22
|
+
segments: ['iframe', 'login', 'success'],
|
|
23
|
+
auth: 'exempt',
|
|
24
|
+
handler: getLoginSuccess
|
|
25
|
+
},
|
|
26
|
+
{ method: 'GET', segments: ['release'], auth: 'required', handler: getRelease },
|
|
27
|
+
{ method: 'POST', segments: ['release', 'items'], auth: 'required', handler: postReleaseItems },
|
|
28
|
+
{
|
|
29
|
+
method: 'DELETE',
|
|
30
|
+
segments: ['release', 'items'],
|
|
31
|
+
auth: 'required',
|
|
32
|
+
handler: deleteReleaseItems
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
method: 'POST',
|
|
36
|
+
segments: ['release', 'publish'],
|
|
37
|
+
auth: 'required',
|
|
38
|
+
handler: postReleasePublish
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
method: 'POST',
|
|
42
|
+
segments: ['release', 'discard'],
|
|
43
|
+
auth: 'required',
|
|
44
|
+
handler: postReleaseDiscard
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
method: 'POST',
|
|
48
|
+
segments: ['release', 'preview-key'],
|
|
49
|
+
auth: 'required',
|
|
50
|
+
handler: postReleasePreviewKey
|
|
51
|
+
},
|
|
52
|
+
{ method: 'GET', segments: ['release', 'history'], auth: 'required', handler: getReleaseHistory },
|
|
53
|
+
{
|
|
54
|
+
method: 'POST',
|
|
55
|
+
segments: ['release', 'history', ':id', 'revert'],
|
|
56
|
+
auth: 'required',
|
|
57
|
+
handler: postReleaseRevert
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
method: 'POST',
|
|
61
|
+
segments: ['release', 'history', ':id', 'preview-key'],
|
|
62
|
+
auth: 'required',
|
|
63
|
+
handler: postReleaseHistoryPreviewKey
|
|
64
|
+
},
|
|
65
|
+
{ method: 'GET', segments: ['media'], auth: 'required', handler: getMedia },
|
|
66
|
+
{ method: 'POST', segments: ['media'], auth: 'required', handler: postMedia },
|
|
67
|
+
{ method: 'DELETE', segments: ['media', ':id'], auth: 'required', handler: deleteMediaItem },
|
|
68
|
+
// Served from the mount as well as (optionally) the origin root, so a
|
|
69
|
+
// single-tenant app needs only one mount to be self-contained.
|
|
70
|
+
{ method: 'GET', segments: ['uploads', ':filename'], auth: 'public', handler: getUpload },
|
|
71
|
+
{ method: 'POST', segments: ['__test_reset__'], auth: 'public', handler: postTestReset }
|
|
72
|
+
];
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PageEntry } from '../../core/page-entry.js';
|
|
2
|
+
import type { Tree } from '../../core/path.js';
|
|
3
|
+
import { type RouteCtx } from './context.js';
|
|
4
|
+
export type SeedInput = {
|
|
5
|
+
layoutDocs?: Record<string, unknown>;
|
|
6
|
+
pageDocs?: Record<string, unknown>;
|
|
7
|
+
};
|
|
8
|
+
/** Normalise either seed shape into `[locale][routeId]` buckets. */
|
|
9
|
+
export declare const normalizeSeed: (seed: SeedInput) => {
|
|
10
|
+
layouts: Record<string, Record<string, Tree>>;
|
|
11
|
+
pages: Record<string, Record<string, PageEntry[]>>;
|
|
12
|
+
};
|
|
13
|
+
/** Remove only files this backend could have written. */
|
|
14
|
+
export declare const clearUploads: (uploadDir: string) => Promise<void>;
|
|
15
|
+
export declare const postTestReset: (ctx: RouteCtx) => Promise<Response>;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `POST /__test_reset__` — wipe one project's CMS data and optionally seed it.
|
|
3
|
+
*
|
|
4
|
+
* Off unless the host sets `testReset`. It exists so an integration suite (or a
|
|
5
|
+
* developer poking at `vela dev`) can stand up a known fixture in one
|
|
6
|
+
* round-trip; the in-process test client calls the same code path through
|
|
7
|
+
* `backend.testing.reset`, which keeps the two honest.
|
|
8
|
+
*
|
|
9
|
+
* One deliberate change from the original: that version did
|
|
10
|
+
* `rm(uploadDir(), { recursive: true, force: true })` — a recursive delete of a
|
|
11
|
+
* host-configured directory, on an unauthenticated POST. Here only files whose
|
|
12
|
+
* names this backend could have generated are unlinked, and never the directory
|
|
13
|
+
* itself, so a misconfigured `uploadDir` cannot be destroyed.
|
|
14
|
+
*/
|
|
15
|
+
import { readdir, unlink } from 'node:fs/promises';
|
|
16
|
+
import { DEFAULT_LOCALE } from '../store/queries.js';
|
|
17
|
+
import { isSafeUploadFilename } from '../storage.js';
|
|
18
|
+
import { badRequest, isObjectRecord, readJson } from './context.js';
|
|
19
|
+
/**
|
|
20
|
+
* Two accepted seed shapes, told apart by inspecting the first level:
|
|
21
|
+
* flat (`{ '/': {...} }`, nested under the default locale) or per-locale
|
|
22
|
+
* (`{ en: { '/': {...} } }`).
|
|
23
|
+
*/
|
|
24
|
+
const isLayoutFlatShape = (m) => {
|
|
25
|
+
for (const v of Object.values(m)) {
|
|
26
|
+
if (!isObjectRecord(v))
|
|
27
|
+
return true;
|
|
28
|
+
const keys = Object.keys(v);
|
|
29
|
+
if (keys.length === 0)
|
|
30
|
+
return true;
|
|
31
|
+
if (!keys.every((k) => k.startsWith('/')))
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
};
|
|
36
|
+
const isPagesFlatShape = (m) => {
|
|
37
|
+
for (const v of Object.values(m)) {
|
|
38
|
+
if (Array.isArray(v))
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
};
|
|
43
|
+
/** Normalise either seed shape into `[locale][routeId]` buckets. */
|
|
44
|
+
export const normalizeSeed = (seed) => {
|
|
45
|
+
const layouts = {};
|
|
46
|
+
const pages = {};
|
|
47
|
+
if (isObjectRecord(seed.layoutDocs)) {
|
|
48
|
+
if (isLayoutFlatShape(seed.layoutDocs)) {
|
|
49
|
+
const bucket = (layouts[DEFAULT_LOCALE] ??= {});
|
|
50
|
+
for (const [k, v] of Object.entries(seed.layoutDocs)) {
|
|
51
|
+
if (isObjectRecord(v))
|
|
52
|
+
bucket[k] = v;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
for (const [locale, byRoute] of Object.entries(seed.layoutDocs)) {
|
|
57
|
+
if (!isObjectRecord(byRoute))
|
|
58
|
+
continue;
|
|
59
|
+
const bucket = (layouts[locale] ??= {});
|
|
60
|
+
for (const [k, v] of Object.entries(byRoute)) {
|
|
61
|
+
if (isObjectRecord(v))
|
|
62
|
+
bucket[k] = v;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (isObjectRecord(seed.pageDocs)) {
|
|
68
|
+
if (isPagesFlatShape(seed.pageDocs)) {
|
|
69
|
+
const bucket = (pages[DEFAULT_LOCALE] ??= {});
|
|
70
|
+
for (const [k, v] of Object.entries(seed.pageDocs)) {
|
|
71
|
+
if (Array.isArray(v))
|
|
72
|
+
bucket[k] = v;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
for (const [locale, byRoute] of Object.entries(seed.pageDocs)) {
|
|
77
|
+
if (!isObjectRecord(byRoute))
|
|
78
|
+
continue;
|
|
79
|
+
const bucket = (pages[locale] ??= {});
|
|
80
|
+
for (const [k, v] of Object.entries(byRoute)) {
|
|
81
|
+
if (Array.isArray(v))
|
|
82
|
+
bucket[k] = v;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { layouts, pages };
|
|
88
|
+
};
|
|
89
|
+
/** Remove only files this backend could have written. */
|
|
90
|
+
export const clearUploads = async (uploadDir) => {
|
|
91
|
+
let names;
|
|
92
|
+
try {
|
|
93
|
+
names = await readdir(uploadDir);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
await Promise.all(names.filter(isSafeUploadFilename).map((n) => unlink(`${uploadDir}/${n}`).catch(() => { })));
|
|
99
|
+
};
|
|
100
|
+
export const postTestReset = async (ctx) => {
|
|
101
|
+
const body = await readJson(ctx.event);
|
|
102
|
+
if (body !== undefined && !isObjectRecord(body))
|
|
103
|
+
return badRequest('invalid json');
|
|
104
|
+
ctx.store.__resetProjectForTests(ctx.projectId);
|
|
105
|
+
ctx.cache.clear();
|
|
106
|
+
await clearUploads(ctx.storage.dir());
|
|
107
|
+
const { layouts, pages } = normalizeSeed((body ?? {}));
|
|
108
|
+
if (Object.keys(layouts).length > 0 || Object.keys(pages).length > 0) {
|
|
109
|
+
ctx.store.seedPublishedDocs(ctx.projectId, layouts, pages);
|
|
110
|
+
}
|
|
111
|
+
return new Response(null, { status: 204 });
|
|
112
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /user` and `POST /logout`.
|
|
3
|
+
*
|
|
4
|
+
* Resolution and project authorization both happen in the router before a
|
|
5
|
+
* handler runs, so `/user` is a pure read of what it decided.
|
|
6
|
+
*/
|
|
7
|
+
import { type RouteCtx } from './context.js';
|
|
8
|
+
export declare const getUser: (ctx: RouteCtx) => Response;
|
|
9
|
+
/**
|
|
10
|
+
* Idempotent — safe to call without being signed in. Clears the cookie with
|
|
11
|
+
* the same attributes it was set with, so the browser accepts the expiry, and
|
|
12
|
+
* lets the adapter drop server-side session state.
|
|
13
|
+
*/
|
|
14
|
+
export declare const postLogout: (ctx: RouteCtx) => Promise<Response>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /user` and `POST /logout`.
|
|
3
|
+
*
|
|
4
|
+
* Resolution and project authorization both happen in the router before a
|
|
5
|
+
* handler runs, so `/user` is a pure read of what it decided.
|
|
6
|
+
*/
|
|
7
|
+
import { requireUser } from './context.js';
|
|
8
|
+
export const getUser = (ctx) => {
|
|
9
|
+
const user = requireUser(ctx);
|
|
10
|
+
return Response.json({ user: { id: user.id, name: user.name, email: user.email } });
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Idempotent — safe to call without being signed in. Clears the cookie with
|
|
14
|
+
* the same attributes it was set with, so the browser accepts the expiry, and
|
|
15
|
+
* lets the adapter drop server-side session state.
|
|
16
|
+
*/
|
|
17
|
+
export const postLogout = async (ctx) => {
|
|
18
|
+
await ctx.auth.logout?.(ctx.event, ctx.authCtx);
|
|
19
|
+
ctx.clearSessionCookie();
|
|
20
|
+
return new Response(null, { status: 204 });
|
|
21
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem helpers for the media library, bound to one upload directory.
|
|
3
|
+
*
|
|
4
|
+
* The directory is supplied by the host rather than derived here. Uploads must
|
|
5
|
+
* live outside `static/`, which is a build-time input — a file written there at
|
|
6
|
+
* runtime is served by Vite in development and by nothing at all in production.
|
|
7
|
+
* A directory that outlives a release (the app's data directory) is the right
|
|
8
|
+
* answer, and only the host knows where that is.
|
|
9
|
+
*/
|
|
10
|
+
export declare const createStorage: (uploadDir: string) => {
|
|
11
|
+
/** The directory uploaded bytes are written to. */
|
|
12
|
+
dir: () => string;
|
|
13
|
+
/** The path an uploaded file is stored at. Callers pass a validated filename. */
|
|
14
|
+
path: (filename: string) => string;
|
|
15
|
+
};
|
|
16
|
+
export type CmsStorage = ReturnType<typeof createStorage>;
|
|
17
|
+
export declare const isSafeUploadFilename: (name: string) => boolean;
|
|
18
|
+
export declare const uploadContentType: (filename: string) => string;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
/**
|
|
3
|
+
* Filesystem helpers for the media library, bound to one upload directory.
|
|
4
|
+
*
|
|
5
|
+
* The directory is supplied by the host rather than derived here. Uploads must
|
|
6
|
+
* live outside `static/`, which is a build-time input — a file written there at
|
|
7
|
+
* runtime is served by Vite in development and by nothing at all in production.
|
|
8
|
+
* A directory that outlives a release (the app's data directory) is the right
|
|
9
|
+
* answer, and only the host knows where that is.
|
|
10
|
+
*/
|
|
11
|
+
export const createStorage = (uploadDir) => ({
|
|
12
|
+
/** The directory uploaded bytes are written to. */
|
|
13
|
+
dir: () => uploadDir,
|
|
14
|
+
/** The path an uploaded file is stored at. Callers pass a validated filename. */
|
|
15
|
+
path: (filename) => join(uploadDir, filename)
|
|
16
|
+
});
|
|
17
|
+
/**
|
|
18
|
+
* Filenames this app generates: sixteen hex characters and an extension. The
|
|
19
|
+
* pattern is deliberately a little wider than that, so a name from an older
|
|
20
|
+
* upload still resolves, and deliberately excludes `/` and `.` so that a
|
|
21
|
+
* request can only ever name a file directly inside {@link uploadDir}.
|
|
22
|
+
*/
|
|
23
|
+
const SAFE_FILENAME = /^[a-zA-Z0-9_-]{1,64}\.[a-zA-Z0-9]{1,8}$/;
|
|
24
|
+
export const isSafeUploadFilename = (name) => SAFE_FILENAME.test(name);
|
|
25
|
+
/**
|
|
26
|
+
* Content type for a stored upload, by extension.
|
|
27
|
+
*
|
|
28
|
+
* Only the types `POST /media` accepts are named. Anything else is served as
|
|
29
|
+
* bytes rather than guessed at — the browser is told not to sniff, so an
|
|
30
|
+
* unrecognized extension downloads instead of executing.
|
|
31
|
+
*/
|
|
32
|
+
const EXT_TO_MIME = {
|
|
33
|
+
'.jpg': 'image/jpeg',
|
|
34
|
+
'.jpeg': 'image/jpeg',
|
|
35
|
+
'.png': 'image/png',
|
|
36
|
+
'.webp': 'image/webp',
|
|
37
|
+
'.gif': 'image/gif',
|
|
38
|
+
'.svg': 'image/svg+xml',
|
|
39
|
+
'.avif': 'image/avif'
|
|
40
|
+
};
|
|
41
|
+
export const uploadContentType = (filename) => {
|
|
42
|
+
const dot = filename.lastIndexOf('.');
|
|
43
|
+
const ext = dot === -1 ? '' : filename.slice(dot).toLowerCase();
|
|
44
|
+
return EXT_TO_MIME[ext] ?? 'application/octet-stream';
|
|
45
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-instance LRU for hot read responses.
|
|
3
|
+
*
|
|
4
|
+
* Keys always include the project's `cms_version`, so a publish makes every
|
|
5
|
+
* stale entry unreachable and no active eviction is needed. Positive-only: 404s
|
|
6
|
+
* are never cached, so a flood of requests for empty tenants can't evict real
|
|
7
|
+
* content.
|
|
8
|
+
*
|
|
9
|
+
* Hand-rolled rather than pulling in `lru-cache`. This is ~30 lines and only
|
|
10
|
+
* `@velastack/cms/backend` ever touches it — a dependency here would be
|
|
11
|
+
* installed by every consumer of the components and the SSR read path too,
|
|
12
|
+
* none of whom run a backend.
|
|
13
|
+
*
|
|
14
|
+
* In a clustered deployment each worker keeps its own cache; cross-worker hit
|
|
15
|
+
* rate is recovered by the CDN in front, which the `Cache-Control` and `ETag`
|
|
16
|
+
* headers are sized for.
|
|
17
|
+
*/
|
|
18
|
+
export type ReadCacheKey = {
|
|
19
|
+
projectId: string;
|
|
20
|
+
cmsVersion: number;
|
|
21
|
+
scope: string;
|
|
22
|
+
};
|
|
23
|
+
export type ReadCache = {
|
|
24
|
+
get(key: ReadCacheKey): string | undefined;
|
|
25
|
+
set(key: ReadCacheKey, value: string): void;
|
|
26
|
+
clear(): void;
|
|
27
|
+
};
|
|
28
|
+
export type ReadCacheOptions = {
|
|
29
|
+
/** Entries retained before the least-recently-used is evicted. */
|
|
30
|
+
max?: number;
|
|
31
|
+
/** Per-entry lifetime in milliseconds. */
|
|
32
|
+
ttlMs?: number;
|
|
33
|
+
};
|
|
34
|
+
export declare const createReadCache: (options?: ReadCacheOptions) => ReadCache;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const buildKey = ({ projectId, cmsVersion, scope }) => `${projectId}:${cmsVersion}:${scope}`;
|
|
2
|
+
export const createReadCache = (options = {}) => {
|
|
3
|
+
const max = options.max ?? 50_000;
|
|
4
|
+
const ttlMs = options.ttlMs ?? 5 * 60 * 1000;
|
|
5
|
+
// A Map iterates in insertion order, so the first key is the oldest. `get`
|
|
6
|
+
// re-inserts on a hit, which is what makes the eviction order LRU rather
|
|
7
|
+
// than FIFO.
|
|
8
|
+
const entries = new Map();
|
|
9
|
+
return {
|
|
10
|
+
get(key) {
|
|
11
|
+
const k = buildKey(key);
|
|
12
|
+
const hit = entries.get(k);
|
|
13
|
+
if (!hit)
|
|
14
|
+
return undefined;
|
|
15
|
+
if (hit.expiresAt <= Date.now()) {
|
|
16
|
+
entries.delete(k);
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
entries.delete(k);
|
|
20
|
+
entries.set(k, hit);
|
|
21
|
+
return hit.value;
|
|
22
|
+
},
|
|
23
|
+
set(key, value) {
|
|
24
|
+
const k = buildKey(key);
|
|
25
|
+
entries.delete(k);
|
|
26
|
+
entries.set(k, { value, expiresAt: Date.now() + ttlMs });
|
|
27
|
+
while (entries.size > max) {
|
|
28
|
+
const oldest = entries.keys().next();
|
|
29
|
+
if (oldest.done)
|
|
30
|
+
break;
|
|
31
|
+
entries.delete(oldest.value);
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
clear() {
|
|
35
|
+
entries.clear();
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type SqliteDb } from './sqlite.js';
|
|
2
|
+
/**
|
|
3
|
+
* Open the CMS database and bring its schema up to date.
|
|
4
|
+
*
|
|
5
|
+
* Unlike the version this was lifted from, there is no module-level singleton
|
|
6
|
+
* and no `CMS_DB_PATH` lookup: the path is supplied by the host, which is what
|
|
7
|
+
* lets one process host two backends and lets each test file hold its own
|
|
8
|
+
* `':memory:'` database.
|
|
9
|
+
*/
|
|
10
|
+
export declare const openDatabase: (dbPath: string) => SqliteDb;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { dirname } from 'node:path';
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { loadDriver } from './sqlite.js';
|
|
4
|
+
import { runMigrations } from './migrate.js';
|
|
5
|
+
import { MIGRATIONS } from './migrations/index.js';
|
|
6
|
+
/**
|
|
7
|
+
* Open the CMS database and bring its schema up to date.
|
|
8
|
+
*
|
|
9
|
+
* Unlike the version this was lifted from, there is no module-level singleton
|
|
10
|
+
* and no `CMS_DB_PATH` lookup: the path is supplied by the host, which is what
|
|
11
|
+
* lets one process host two backends and lets each test file hold its own
|
|
12
|
+
* `':memory:'` database.
|
|
13
|
+
*/
|
|
14
|
+
export const openDatabase = (dbPath) => {
|
|
15
|
+
const Driver = loadDriver();
|
|
16
|
+
if (dbPath !== ':memory:') {
|
|
17
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
const db = new Driver(dbPath);
|
|
20
|
+
db.pragma('journal_mode = WAL');
|
|
21
|
+
db.pragma('synchronous = NORMAL');
|
|
22
|
+
db.pragma('cache_size = -65536');
|
|
23
|
+
db.pragma('mmap_size = 268435456');
|
|
24
|
+
db.pragma('foreign_keys = ON');
|
|
25
|
+
db.pragma('temp_store = MEMORY');
|
|
26
|
+
db.pragma('busy_timeout = 5000');
|
|
27
|
+
runMigrations(db, MIGRATIONS);
|
|
28
|
+
return db;
|
|
29
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { SqliteDb } from './sqlite.js';
|
|
2
|
+
import type { Migration } from './migrations/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* Apply any unapplied migrations, in order, each in its own transaction, and
|
|
5
|
+
* record it in `_migrations` so re-runs are no-ops.
|
|
6
|
+
*
|
|
7
|
+
* Safe to call from every worker at boot — SQLite's writer lock serialises the
|
|
8
|
+
* work and later workers find the rows already present.
|
|
9
|
+
*/
|
|
10
|
+
export declare const runMigrations: (db: SqliteDb, migrations: Migration[]) => void;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply any unapplied migrations, in order, each in its own transaction, and
|
|
3
|
+
* record it in `_migrations` so re-runs are no-ops.
|
|
4
|
+
*
|
|
5
|
+
* Safe to call from every worker at boot — SQLite's writer lock serialises the
|
|
6
|
+
* work and later workers find the rows already present.
|
|
7
|
+
*/
|
|
8
|
+
export const runMigrations = (db, migrations) => {
|
|
9
|
+
db.exec(`
|
|
10
|
+
CREATE TABLE IF NOT EXISTS _migrations (
|
|
11
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
12
|
+
name TEXT NOT NULL UNIQUE,
|
|
13
|
+
applied_at TEXT NOT NULL
|
|
14
|
+
);
|
|
15
|
+
`);
|
|
16
|
+
const applied = new Set(db
|
|
17
|
+
.prepare('SELECT name FROM _migrations')
|
|
18
|
+
.all()
|
|
19
|
+
.map((r) => r.name));
|
|
20
|
+
const insert = db.prepare('INSERT INTO _migrations (name, applied_at) VALUES (?, ?)');
|
|
21
|
+
for (const { name, sql } of migrations) {
|
|
22
|
+
if (applied.has(name))
|
|
23
|
+
continue;
|
|
24
|
+
const tx = db.transaction(() => {
|
|
25
|
+
db.exec(sql);
|
|
26
|
+
insert.run(name, new Date().toISOString());
|
|
27
|
+
});
|
|
28
|
+
tx();
|
|
29
|
+
}
|
|
30
|
+
};
|