domma-cms 0.91.1 → 0.92.1
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/CLAUDE.md +18 -9
- package/admin/js/lib/collection-editor-arrange.js +2 -2
- package/admin/js/lib/view-editor-arrange.js +1 -1
- package/admin/js/templates/collection-editor.html +3 -1
- package/admin/js/templates/docs/api-users.html +8 -8
- package/admin/js/templates/docs/api-views.html +5 -4
- package/admin/js/templates/docs/usage-users-roles.html +28 -48
- package/admin/js/templates/docs/usage-views.html +6 -4
- package/admin/js/views/collection-editor.js +4 -4
- package/admin/js/views/view-editor.js +1 -1
- package/package.json +1 -1
- package/plugins/feedback/CLAUDE.md +96 -0
- package/plugins/feedback/admin/css/index.css +80 -0
- package/plugins/feedback/admin/lib/kit.js +105 -0
- package/plugins/feedback/admin/templates/feedback.html +1 -0
- package/plugins/feedback/admin/views/feedback.js +63 -0
- package/plugins/feedback/admin/views/inbox.js +325 -0
- package/plugins/feedback/admin/views/send.js +223 -0
- package/plugins/feedback/collections/feedback-reports.json +24 -0
- package/plugins/feedback/config.js +15 -0
- package/plugins/feedback/lib/receiver.js +121 -0
- package/plugins/feedback/lib/sender.js +161 -0
- package/plugins/feedback/lib/shape.js +211 -0
- package/plugins/feedback/plugin.js +276 -0
- package/plugins/feedback/plugin.json +48 -0
- package/plugins/feedback/tests/api.test.js +219 -0
- package/plugins/feedback/tests/shape.test.js +87 -0
- package/plugins/free-tier.lock.json +22 -1
- package/server/routes/api/collections.js +8 -1
- package/server/routes/api/search.js +27 -6
- package/server/routes/api/views.js +19 -20
- package/server/routes/public.js +9 -0
- package/server/services/content.js +6 -4
- package/server/services/markdown.js +66 -33
- package/server/services/rowAccess.js +49 -0
- package/server/services/search.js +145 -17
- package/server/services/viewerSession.js +2 -2
- package/server/services/views.js +67 -5
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"note": "Written by `make sync-free-tier` in dcms-marketplace. Do not edit these plugins here - edit them there and sync.",
|
|
3
|
-
"source": "dcms-marketplace@
|
|
3
|
+
"source": "dcms-marketplace@9434f03",
|
|
4
4
|
"plugins": {
|
|
5
5
|
"blog": {
|
|
6
6
|
"version": "1.9.0",
|
|
@@ -71,6 +71,27 @@
|
|
|
71
71
|
"tests/public.test.js": "a6fcdc4ede242aa050ffe5a52f970d6bec1f4a70ae78967eb8a7bdeec73df19d"
|
|
72
72
|
}
|
|
73
73
|
},
|
|
74
|
+
"feedback": {
|
|
75
|
+
"version": "1.0.0",
|
|
76
|
+
"files": {
|
|
77
|
+
"CLAUDE.md": "8cdfa2ccf028cb6b9e4a185dc81e5547927132bda9b6326725b99fc221bc6758",
|
|
78
|
+
"admin/css/index.css": "f2e934c8b94f883340148aba17945dfa725eeb2b0ddb6cabb8a8c17e2487c1de",
|
|
79
|
+
"admin/lib/kit.js": "3e153c8e1f14ab88c2ed847e65d3b3f597ef833603f2e4a50aa878f10f8c490e",
|
|
80
|
+
"admin/templates/feedback.html": "a366dce78f6c3e84a7cb0ac63eba53e1980a4eb44154496a0536f5214c5bb36f",
|
|
81
|
+
"admin/views/feedback.js": "11da66fd3aa18dabdc6a334ebd0eb55040ece95128a8f1cfbf08d89b1765cac1",
|
|
82
|
+
"admin/views/inbox.js": "649b86844230be73939d82aa7c97432252c0b119baa102ebf53808e1d5f23737",
|
|
83
|
+
"admin/views/send.js": "1a876a20cf34e1348a6ee7239432801dc9f26852a2e65d386c34b0f9674856b2",
|
|
84
|
+
"collections/feedback-reports.json": "a072291b52463f7785a77f5f77d36b8238ba8f89cfc2672c6642eccdb3144486",
|
|
85
|
+
"config.js": "29d9999d1028c2c11931f67fb9abdf35132430b7cf58c161681e734c0b45143a",
|
|
86
|
+
"lib/receiver.js": "39b2ebac0a705b4cf182913ec8d006dff4b522fb5aaf6023747b48137559ea6d",
|
|
87
|
+
"lib/sender.js": "efb00d06ec149843ffd746f68f3c1ba94333f2205942a786bb37fa91eb3e9806",
|
|
88
|
+
"lib/shape.js": "67acf62f812c2c9a01b52776641ed7ae1601c59ebab8ea278a07d1087a9f299b",
|
|
89
|
+
"plugin.js": "a2f4cccc7ec38cf3877db8390a880e45d1b0e46a61df8dc60ac1300285fe66ae",
|
|
90
|
+
"plugin.json": "4c92ad716deb845058db90cbc67c2e386e6d9f85d1c1d60481eb6b130dbc427e",
|
|
91
|
+
"tests/api.test.js": "6984a2a8c31b06b0ccb1092853567856fc79027345b26e8114dcce631a0949e9",
|
|
92
|
+
"tests/shape.test.js": "5133578142a79cc1a100b3215436372129f01fb0de8c757fd743ff99e1d7e37f"
|
|
93
|
+
}
|
|
94
|
+
},
|
|
74
95
|
"mail-reader": {
|
|
75
96
|
"version": "1.2.1",
|
|
76
97
|
"files": {
|
|
@@ -232,7 +232,14 @@ export async function checkPublicAccess(schema, operation, request, reply) {
|
|
|
232
232
|
}
|
|
233
233
|
|
|
234
234
|
const user = request.user;
|
|
235
|
-
|
|
235
|
+
// A role name that is not a role on this site (deleted, mistyped, or one of
|
|
236
|
+
// the subscriber/editor/manager names older collection editors offered)
|
|
237
|
+
// resolves to level Infinity, and `userLevel > Infinity` is never true - so
|
|
238
|
+
// it used to let ANY signed-in user through. Fail closed instead: only the
|
|
239
|
+
// level-0 role passes, the same rule checkVisibility() applies to a page
|
|
240
|
+
// whose visibility names an unknown role (middleware/auth.js).
|
|
241
|
+
const namedLevel = roleLevel(access.access);
|
|
242
|
+
const requiredLevel = Number.isFinite(namedLevel) ? namedLevel : 0;
|
|
236
243
|
// Effective level - multi-role users get the lowest (most privileged) level
|
|
237
244
|
// across their primary + additional roles for this access decision.
|
|
238
245
|
const userLevel = getEffectiveLevel(user);
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Search API Routes
|
|
3
3
|
*
|
|
4
|
-
* GET /api/search?q=… - public: full-text search across published pages
|
|
4
|
+
* GET /api/search?q=… - public: full-text search across the published pages
|
|
5
|
+
* the viewer may open (resolveViewer(): Bearer, else the dm_session cookie
|
|
6
|
+
* the overlay's same-origin fetch carries) - noindex pages (frontmatter or
|
|
7
|
+
* the SEO screen's per-URL override) and pages in a disabled project never
|
|
5
8
|
* GET /api/search/settings - public: the search UI's own configuration
|
|
6
9
|
* PUT /api/search/settings - admin-only: save it
|
|
7
10
|
* GET /api/search/preview?q=…&max=… - admin-only: the Search screen's "Try it" -
|
|
8
11
|
* the same search, run with the max-results being edited (not yet saved)
|
|
9
|
-
* and without the minimum length, so a setting can be tried before Save
|
|
12
|
+
* and without the minimum length, so a setting can be tried before Save.
|
|
13
|
+
* Runs as an anonymous visitor, so it shows what the public finds
|
|
10
14
|
* GET /api/search/_count - admin-only: the sidebar badge (searchBadge)
|
|
11
15
|
*
|
|
12
16
|
* The public settings endpoint is kept for anything already calling it (and
|
|
@@ -17,17 +21,20 @@
|
|
|
17
21
|
import {authenticate as defaultAuthenticate, requireAdmin as defaultRequireAdmin,
|
|
18
22
|
requirePermission as defaultRequirePermission} from '../../middleware/auth.js';
|
|
19
23
|
import {listPages} from '../../services/content.js';
|
|
20
|
-
import {getSearchSettings, saveSearchSettings, searchBadge, searchCoverage, searchPages}
|
|
24
|
+
import {getSearchSettings, saveSearchSettings, searchBadge, searchCoverage, searchExclusions, searchPages}
|
|
25
|
+
from '../../services/search.js';
|
|
26
|
+
import {resolveViewer as defaultResolveViewer} from '../../services/viewerSession.js';
|
|
21
27
|
|
|
22
28
|
/**
|
|
23
29
|
* @param {import('fastify').FastifyInstance} fastify
|
|
24
|
-
* @param {{authenticate?: Function, requireAdmin?: Function}} [opts] - DI for tests
|
|
30
|
+
* @param {{authenticate?: Function, requireAdmin?: Function, resolveViewer?: Function}} [opts] - DI for tests
|
|
25
31
|
*/
|
|
26
32
|
export async function searchRoutes(fastify, opts = {}) {
|
|
27
33
|
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
28
34
|
const requireAdmin = opts.requireAdmin || defaultRequireAdmin;
|
|
29
35
|
const adminOnly = {preHandler: [authenticate, requireAdmin]};
|
|
30
36
|
const requirePermission = opts.requirePermission || defaultRequirePermission;
|
|
37
|
+
const resolveViewer = opts.resolveViewer || defaultResolveViewer;
|
|
31
38
|
// The badge follows the Search sidebar entry's own permission (settings), so whoever
|
|
32
39
|
// sees the entry can read it - admin-only, it 403'd every minute for the rest. It
|
|
33
40
|
// tells nothing a visitor's search does not: public pages only.
|
|
@@ -43,9 +50,18 @@ export async function searchRoutes(fastify, opts = {}) {
|
|
|
43
50
|
});
|
|
44
51
|
}
|
|
45
52
|
|
|
53
|
+
// A read with no side effects, like a page navigation - so the viewer is
|
|
54
|
+
// resolved the way the public renderer does it, and a result is only
|
|
55
|
+
// ever a page this viewer would be served. authenticate() stays out of
|
|
56
|
+
// it: an anonymous search is the common case, not a 401. Responses are
|
|
57
|
+
// no-store (the /api onSend hook) and never enter the response cache,
|
|
58
|
+
// so nothing gated can be replayed to another viewer.
|
|
59
|
+
const viewer = await resolveViewer(request);
|
|
46
60
|
const pages = await listPages();
|
|
47
61
|
const results = searchPages(pages, q, {
|
|
48
62
|
maxResults: settings.maxResults,
|
|
63
|
+
viewer,
|
|
64
|
+
exclusions: await searchExclusions(pages),
|
|
49
65
|
onError: (err, page) =>
|
|
50
66
|
request.log.warn({err, page: page.urlPath}, '[search] skipped an unreadable page')
|
|
51
67
|
});
|
|
@@ -66,12 +82,17 @@ export async function searchRoutes(fastify, opts = {}) {
|
|
|
66
82
|
const asked = parseInt(request.query.max, 10);
|
|
67
83
|
const maxResults = Number.isFinite(asked) ? Math.min(50, Math.max(1, asked)) : settings.maxResults;
|
|
68
84
|
const pages = await listPages();
|
|
85
|
+
const exclusions = await searchExclusions(pages);
|
|
69
86
|
const results = searchPages(pages, q, {
|
|
70
87
|
maxResults,
|
|
88
|
+
exclusions,
|
|
71
89
|
onError: (err, page) => request.log.warn({err, page: page.urlPath}, '[search] skipped an unreadable page')
|
|
72
90
|
});
|
|
73
|
-
return {q, maxResults, results, coverage: searchCoverage(pages)};
|
|
91
|
+
return {q, maxResults, results, coverage: searchCoverage(pages, exclusions)};
|
|
74
92
|
});
|
|
75
93
|
|
|
76
|
-
fastify.get('/search/_count', canReadBadge, async () =>
|
|
94
|
+
fastify.get('/search/_count', canReadBadge, async () => {
|
|
95
|
+
const pages = await listPages();
|
|
96
|
+
return searchBadge(getSearchSettings(), pages, await searchExclusions(pages));
|
|
97
|
+
});
|
|
77
98
|
}
|
|
@@ -12,13 +12,11 @@
|
|
|
12
12
|
* POST /views/_preview - Run an UNSAVED config (the editor's Results): {source, connection, stages, limit}
|
|
13
13
|
* GET /views/collection/:slug - List views targeting a collection
|
|
14
14
|
*
|
|
15
|
-
* Public endpoint (
|
|
15
|
+
* Public endpoint (checked against the view's own access - canReadView):
|
|
16
16
|
* GET /views/:slug/public - Execute view publicly
|
|
17
17
|
*/
|
|
18
|
-
import {createView, deleteView, executeView, executeViewConfig, getView, listViews, updateView, viewsBadge} from '../../services/views.js';
|
|
18
|
+
import {canReadView, createView, deleteView, executeView, executeViewConfig, getView, listViews, updateView, viewsBadge} from '../../services/views.js';
|
|
19
19
|
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
20
|
-
import {getRoleLevel} from '../../services/roles.js';
|
|
21
|
-
import {getEffectiveLevel} from '../../services/userRoles.js';
|
|
22
20
|
|
|
23
21
|
/**
|
|
24
22
|
* Every collection a pipeline's $lookup stages read, as slugs (`cms_` stripped - Mongo-authored views
|
|
@@ -203,29 +201,30 @@ export async function viewsRoutes(fastify) {
|
|
|
203
201
|
|
|
204
202
|
if (!view) return reply.status(404).send({ error: 'View not found' });
|
|
205
203
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const userLevel = getEffectiveLevel(user);
|
|
216
|
-
const minAllowed = Math.min(...allowedRoles.map(r => getRoleLevel(r)));
|
|
204
|
+
// Identify the caller even on a public view: row-level scoping needs
|
|
205
|
+
// them, and an anonymous run of a row-scoped view is empty.
|
|
206
|
+
let user = null;
|
|
207
|
+
try {
|
|
208
|
+
const decoded = await request.jwtVerify();
|
|
209
|
+
if (decoded?.type === 'access') user = request.user || null;
|
|
210
|
+
} catch {
|
|
211
|
+
user = null;
|
|
212
|
+
}
|
|
217
213
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
214
|
+
// The same decision the [view] shortcode makes (canReadView), so the
|
|
215
|
+
// page and the endpoint it pages from never disagree.
|
|
216
|
+
if (!canReadView(user, view)) {
|
|
217
|
+
return user
|
|
218
|
+
? reply.status(403).send({ error: 'Insufficient permissions' })
|
|
219
|
+
: reply.status(401).send({ error: 'Unauthorised' });
|
|
221
220
|
}
|
|
222
221
|
|
|
223
222
|
const { page, limit } = request.query;
|
|
224
223
|
try {
|
|
225
|
-
return await
|
|
224
|
+
return await executeViewConfig(view, {
|
|
226
225
|
page: parseInt(page, 10) || 1,
|
|
227
226
|
limit: parseInt(limit, 10) || 25,
|
|
228
|
-
user
|
|
227
|
+
user
|
|
229
228
|
});
|
|
230
229
|
} catch (err) {
|
|
231
230
|
return reply.status(400).send({ error: err.message });
|
package/server/routes/public.js
CHANGED
|
@@ -273,6 +273,15 @@ export async function publicRoutes(fastify) {
|
|
|
273
273
|
return reply.type('text/html').send(await render());
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
// A row-scoped [view] shows each signed-in user their OWN rows, so the
|
|
277
|
+
// render differs per user, not per role set - two users sharing a role
|
|
278
|
+
// must not share an entry. Anonymous viewers of such a view get no rows
|
|
279
|
+
// at all (views.js), which is one output, so they still share the cache.
|
|
280
|
+
if (page.perViewer && userObj) {
|
|
281
|
+
hooks.emit('content:pageViewed', {urlPath, title: page.title || ''});
|
|
282
|
+
return sendHtml(request, reply, await render());
|
|
283
|
+
}
|
|
284
|
+
|
|
276
285
|
// mtime in the key: any change to the backing file - editor save,
|
|
277
286
|
// script, git pull - yields a fresh entry even if no invalidation
|
|
278
287
|
// hook fired. Stale-mtime entries age out via TTL/LRU.
|
|
@@ -98,9 +98,9 @@ export async function getPage(urlPath, opts = {}) {
|
|
|
98
98
|
*/
|
|
99
99
|
export async function buildPreviewPage(urlPath, frontmatter, body, opts = {}) {
|
|
100
100
|
const raw = serialiseMarkdown({...(frontmatter || {})}, body || '');
|
|
101
|
-
const {data, content, html, usedComponents, tags} =
|
|
101
|
+
const {data, content, html, usedComponents, tags, perViewer} =
|
|
102
102
|
await parseMarkdown(raw, {user: opts.user || null, urlPath});
|
|
103
|
-
return {...data, urlPath, content, html, usedComponents, cacheTags: tags};
|
|
103
|
+
return {...data, urlPath, content, html, usedComponents, cacheTags: tags, perViewer};
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
/**
|
|
@@ -411,14 +411,16 @@ async function readPageFile(filePath, opts = {}) {
|
|
|
411
411
|
const urlPath = filePathToUrlPath(filePath);
|
|
412
412
|
// urlPath is passed through so `[menu location="…"]` in the body resolves
|
|
413
413
|
// against menus bound to this page or its project, exactly as the navbar does.
|
|
414
|
-
const { data, content, html, usedComponents, tags } =
|
|
414
|
+
const { data, content, html, usedComponents, tags, perViewer } =
|
|
415
415
|
await parseMarkdown(raw, {user: opts.user || null, urlPath});
|
|
416
416
|
// parseMarkdown's `tags` are CACHE tags (`block:x`, `collection:y`), not
|
|
417
417
|
// the page's editorial tags. Spreading them last used to shadow the
|
|
418
418
|
// frontmatter's `tags`, so the editor loaded `block:feedback-card` into
|
|
419
419
|
// the tag pillbox and wrote it back on save, and the pages list showed
|
|
420
420
|
// cache internals in its Tags column. Keep them apart.
|
|
421
|
-
|
|
421
|
+
// perViewer: a row-scoped [view] renders per user - the public route keeps
|
|
422
|
+
// such a page out of the role-keyed response cache for signed-in viewers.
|
|
423
|
+
return { ...data, urlPath, content, html, usedComponents, cacheTags: tags, perViewer };
|
|
422
424
|
}
|
|
423
425
|
|
|
424
426
|
/**
|
|
@@ -14,7 +14,7 @@ import {getCollection, listEntries} from './collections.js';
|
|
|
14
14
|
import {getMenu, resolveLocation, resolveMenuDecorations} from './menus.js';
|
|
15
15
|
import {buildMenuNav} from './menuRender.js';
|
|
16
16
|
import {checkVisibility} from '../middleware/auth.js';
|
|
17
|
-
import {getRoleHierarchy} from './roles.js';
|
|
17
|
+
import {getRoleHierarchy, getRoleLevel} from './roles.js';
|
|
18
18
|
import {buildTextStyle} from '../../public/js/text-style.mjs';
|
|
19
19
|
|
|
20
20
|
const __dirname_md = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -243,15 +243,20 @@ export function resolveExportConfig(schemaExport, attrs, defaultRole) {
|
|
|
243
243
|
* test on the client.
|
|
244
244
|
*
|
|
245
245
|
* @param {string} access - 'public', or a role name
|
|
246
|
-
* @returns {string[]} empty for 'public' (no check needed)
|
|
246
|
+
* @returns {string[]} empty for 'public' (no check needed); the level-0
|
|
247
|
+
* role(s) only for a role name that is not a role on this site
|
|
247
248
|
*/
|
|
248
249
|
function rolesAtOrAbove(access) {
|
|
249
250
|
if (access === 'public') return [];
|
|
250
251
|
const hierarchy = getRoleHierarchy(); // most → least privileged
|
|
251
252
|
const idx = hierarchy.indexOf(access);
|
|
252
|
-
// Unknown role name
|
|
253
|
-
//
|
|
254
|
-
|
|
253
|
+
// Unknown role name (deleted, mistyped, an old editor's preset name):
|
|
254
|
+
// fail closed - only the level-0 role passes, the same rule
|
|
255
|
+
// checkPublicAccess() (routes/api/collections.js) and page visibility
|
|
256
|
+
// (checkSingleVisibility in middleware/auth.js) apply. An empty list here
|
|
257
|
+
// would hide export from everybody, super-admin included.
|
|
258
|
+
if (idx === -1) return hierarchy.filter(r => getRoleLevel(r) === 0);
|
|
259
|
+
return hierarchy.slice(0, idx + 1);
|
|
255
260
|
}
|
|
256
261
|
|
|
257
262
|
/**
|
|
@@ -869,10 +874,21 @@ function renderCollectionListgroup(entries, opts, ctxOpts = {}) {
|
|
|
869
874
|
* Executes the View's aggregation pipeline and renders results using the
|
|
870
875
|
* same render functions as [collection].
|
|
871
876
|
*
|
|
877
|
+
* The view's own `access` decides what the viewer gets, exactly as it does on
|
|
878
|
+
* `GET /api/views/:slug/public` (`canReadView`): a viewer it refuses gets the
|
|
879
|
+
* empty state - no rows, no field names - and the pipeline never runs. A
|
|
880
|
+
* viewer it admits runs the pipeline AS themselves, so row-level scoping
|
|
881
|
+
* applies. A page holding a row-scoped view renders per user, not per role
|
|
882
|
+
* set, and `flags.perViewer` tells the public route to keep it out of the
|
|
883
|
+
* shared response cache.
|
|
884
|
+
*
|
|
872
885
|
* @param {string} markdown
|
|
886
|
+
* @param {Set<string>} [tagSet]
|
|
887
|
+
* @param {object|null} [user] - Resolved viewer, null for anonymous
|
|
888
|
+
* @param {{perViewer?: boolean}} [flags] - Set when output differs per user
|
|
873
889
|
* @returns {Promise<string>}
|
|
874
890
|
*/
|
|
875
|
-
async function processViewBlocks(markdown, tagSet) {
|
|
891
|
+
async function processViewBlocks(markdown, tagSet, user = null, flags = {}) {
|
|
876
892
|
const {scrubbed, restore} = scrubCodeRegions(markdown);
|
|
877
893
|
const pattern = /\[view([^\]]*?)\/\]/gi;
|
|
878
894
|
const matches = [...scrubbed.matchAll(pattern)];
|
|
@@ -890,13 +906,18 @@ async function processViewBlocks(markdown, tagSet) {
|
|
|
890
906
|
}
|
|
891
907
|
tagSet?.add(`view:${slug}`);
|
|
892
908
|
|
|
909
|
+
const {canReadView, getView} = await import('./views.js');
|
|
910
|
+
const viewConfig = await getView(slug).catch(() => null);
|
|
911
|
+
if (viewConfig?.access?.rowLevel) flags.perViewer = true;
|
|
912
|
+
const allowed = canReadView(user, viewConfig);
|
|
913
|
+
|
|
893
914
|
// Interactive `[view]` - when searchable / sortable / paginate is set,
|
|
894
915
|
// emit a Collection Browser shell against the view's executed dataset.
|
|
895
916
|
// No filter rail (views own their own filter logic) but search, sort,
|
|
896
917
|
// and pagination work as on `[collection]`. This is how cross-collection
|
|
897
918
|
// browsing happens without the shortcode itself having to learn joins.
|
|
898
919
|
if (isInteractiveCollection(attrs)) {
|
|
899
|
-
const shell = await renderViewBrowserShell(attrs);
|
|
920
|
+
const shell = await renderViewBrowserShell(attrs, {viewConfig, allowed, user});
|
|
900
921
|
result = result.replace(fullMatch, shell);
|
|
901
922
|
continue;
|
|
902
923
|
}
|
|
@@ -918,12 +939,16 @@ async function processViewBlocks(markdown, tagSet) {
|
|
|
918
939
|
|
|
919
940
|
let replacement = `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
|
|
920
941
|
|
|
942
|
+
// Missing view or a viewer its access refuses: the empty state, and
|
|
943
|
+
// the pipeline is never run.
|
|
944
|
+
if (!viewConfig || !allowed) {
|
|
945
|
+
result = result.replace(fullMatch, replacement);
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
|
|
921
949
|
try {
|
|
922
|
-
const {
|
|
923
|
-
const
|
|
924
|
-
executeView(slug, {page: 1, limit: limitAttr}),
|
|
925
|
-
getView(slug).catch(() => null)
|
|
926
|
-
]);
|
|
950
|
+
const {executeViewConfig} = await import('./views.js');
|
|
951
|
+
const {results} = await executeViewConfig(viewConfig, {page: 1, limit: limitAttr, user});
|
|
927
952
|
|
|
928
953
|
// Map MongoDB docs to { id, data } shape. MongoAdapter docs have a top-level id field.
|
|
929
954
|
const entries = results.map(doc => ({id: doc.id || '', data: doc.data || doc}));
|
|
@@ -932,7 +957,7 @@ async function processViewBlocks(markdown, tagSet) {
|
|
|
932
957
|
// permission. Views don't own a schema themselves - [dis]allowing
|
|
933
958
|
// export must fall back CLOSED if the source collection can't be
|
|
934
959
|
// resolved, since an open fallback would be a permission bypass.
|
|
935
|
-
const sourceSlug = viewConfig
|
|
960
|
+
const sourceSlug = viewConfig.pipeline?.source || '';
|
|
936
961
|
const sourceSchema = sourceSlug ? await getCollection(sourceSlug) : null;
|
|
937
962
|
const ctxOpts = sourceSchema
|
|
938
963
|
? {
|
|
@@ -1279,34 +1304,37 @@ async function renderCollectionBrowserShell(attrs) {
|
|
|
1279
1304
|
* filters by setting `filterable="…"`, but the field names must exist on the
|
|
1280
1305
|
* view's projected document shape.
|
|
1281
1306
|
*
|
|
1307
|
+
* The embedded first page of data (`data-cb-data`) obeys the view's access
|
|
1308
|
+
* the same way the static render does: a refused viewer gets an empty shell,
|
|
1309
|
+
* and server-mode paging hits `/api/views/:slug/public`, which asks the same
|
|
1310
|
+
* `canReadView`.
|
|
1311
|
+
*
|
|
1282
1312
|
* @param {Record<string, string>} attrs
|
|
1313
|
+
* @param {object} ctx
|
|
1314
|
+
* @param {object|null} ctx.viewConfig - The saved view, null when missing
|
|
1315
|
+
* @param {boolean} ctx.allowed - canReadView() for this viewer
|
|
1316
|
+
* @param {object|null} ctx.user - Viewer, for row-level scoping
|
|
1283
1317
|
* @returns {Promise<string>}
|
|
1284
1318
|
*/
|
|
1285
|
-
async function renderViewBrowserShell(attrs) {
|
|
1319
|
+
async function renderViewBrowserShell(attrs, {viewConfig = null, allowed = false, user = null} = {}) {
|
|
1286
1320
|
const slug = attrs.slug;
|
|
1287
1321
|
const pageSize = parseInt(attrs['page-size'], 10) || parseInt(attrs.limit, 10) || 12;
|
|
1288
1322
|
const declaredMode = attrs.mode === 'server' ? 'server' : 'client';
|
|
1289
1323
|
|
|
1290
1324
|
let entries = [];
|
|
1291
1325
|
let total = 0;
|
|
1292
|
-
let viewConfig = null;
|
|
1293
1326
|
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
const r = await executeView(slug, {page: 1, limit: 500});
|
|
1301
|
-
entries = (r.results || []).map(doc => ({id: doc.id || '', data: doc.data || doc}));
|
|
1302
|
-
total = r.total ?? entries.length;
|
|
1303
|
-
} else {
|
|
1304
|
-
const r = await executeView(slug, {page: 1, limit: pageSize});
|
|
1327
|
+
if (viewConfig && allowed) {
|
|
1328
|
+
try {
|
|
1329
|
+
const {executeViewConfig} = await import('./views.js');
|
|
1330
|
+
// For views we cap aggressively in client mode - views can be expensive aggregations.
|
|
1331
|
+
const limit = declaredMode === 'client' ? 500 : pageSize;
|
|
1332
|
+
const r = await executeViewConfig(viewConfig, {page: 1, limit, user});
|
|
1305
1333
|
entries = (r.results || []).map(doc => ({id: doc.id || '', data: doc.data || doc}));
|
|
1306
1334
|
total = r.total ?? entries.length;
|
|
1335
|
+
} catch {
|
|
1336
|
+
// Pipeline error - emit empty shell.
|
|
1307
1337
|
}
|
|
1308
|
-
} catch {
|
|
1309
|
-
// executeView errored (view missing or pipeline error) - emit empty shell.
|
|
1310
1338
|
}
|
|
1311
1339
|
|
|
1312
1340
|
// Derive a schema-like fields list from the first row so the browser can
|
|
@@ -1315,7 +1343,7 @@ async function renderViewBrowserShell(attrs) {
|
|
|
1315
1343
|
? Object.keys(entries[0].data || {})
|
|
1316
1344
|
.filter(k => !['_id', 'id', 'meta'].includes(k))
|
|
1317
1345
|
.map(k => ({name: k, label: k, type: 'text'}))
|
|
1318
|
-
: (viewConfig?.fields || []);
|
|
1346
|
+
: (allowed ? (viewConfig?.fields || []) : []);
|
|
1319
1347
|
|
|
1320
1348
|
const filterable = (attrs.filterable && typeof attrs.filterable === 'string')
|
|
1321
1349
|
? attrs.filterable.split(',').map(s => s.trim()).filter(Boolean) : [];
|
|
@@ -4178,12 +4206,16 @@ function processCtaBlocks(markdown) {
|
|
|
4178
4206
|
* @param {object} [opts]
|
|
4179
4207
|
* @param {object|null} [opts.user] - Authenticated user (`{role, additionalRoles}`)
|
|
4180
4208
|
* or null for anonymous. Used to filter menu items gated by `visibility` in
|
|
4181
|
-
* the `[menu]` shortcode
|
|
4209
|
+
* the `[menu]` shortcode, and as the viewer a `[view]`'s own access and
|
|
4210
|
+
* row-level scoping are checked against. Backwards compatible - defaults to
|
|
4211
|
+
* anonymous.
|
|
4182
4212
|
* @param {string} [opts.urlPath] - URL of the page being parsed. Lets
|
|
4183
4213
|
* `[menu location="…"]` honour menus bound to this page or its project.
|
|
4184
4214
|
* @param {string|null} [opts.project] - Project the page belongs to; resolved
|
|
4185
4215
|
* from `urlPath` when omitted.
|
|
4186
|
-
* @returns {{ data: object, content: string, html: string }}
|
|
4216
|
+
* @returns {{ data: object, content: string, html: string, tags: string[], perViewer: boolean }}
|
|
4217
|
+
* `perViewer` - the output differs per signed-in USER, not just per role set
|
|
4218
|
+
* (a row-scoped `[view]`), so it must not share a role-keyed cache entry.
|
|
4187
4219
|
*/
|
|
4188
4220
|
export async function parseMarkdown(raw, opts = {}) {
|
|
4189
4221
|
const {data, content} = matter(raw);
|
|
@@ -4196,8 +4228,9 @@ export async function parseMarkdown(raw, opts = {}) {
|
|
|
4196
4228
|
const preprocessed = applyTransforms('markdown:beforeParse', content);
|
|
4197
4229
|
const {output: withComponents, used: usedComponents} = collectAndRewriteComponents(preprocessed);
|
|
4198
4230
|
const tagSet = new Set();
|
|
4231
|
+
const flags = {perViewer: false};
|
|
4199
4232
|
const withCollection = await processCollectionBlocks(withComponents, tagSet);
|
|
4200
|
-
const withView = await processViewBlocks(withCollection, tagSet);
|
|
4233
|
+
const withView = await processViewBlocks(withCollection, tagSet, opts.user || null, flags);
|
|
4201
4234
|
const withStaticBlock = await processStaticBlocks(withView, tagSet);
|
|
4202
4235
|
const menuCtx = opts.urlPath
|
|
4203
4236
|
? {urlPath: opts.urlPath, project: opts.project ?? await resolveProjectForUrl(opts.urlPath)}
|
|
@@ -4308,7 +4341,7 @@ export async function parseMarkdown(raw, opts = {}) {
|
|
|
4308
4341
|
const allowed = new Set(_dmTagAllowlist.map(t => t.replace(/^dm-/, '')));
|
|
4309
4342
|
const filteredUsed = [...usedComponents].filter(name => allowed.has(name));
|
|
4310
4343
|
|
|
4311
|
-
return {data, content, html, usedComponents: filteredUsed, tags: [...tagSet]};
|
|
4344
|
+
return {data, content, html, usedComponents: filteredUsed, tags: [...tagSet], perViewer: flags.perViewer};
|
|
4312
4345
|
}
|
|
4313
4346
|
|
|
4314
4347
|
/**
|
|
@@ -175,3 +175,52 @@ export function buildRowLevelMatch(user, rowLevel) {
|
|
|
175
175
|
// per-entry instead. Return null so the upstream code knows to do that.
|
|
176
176
|
return null;
|
|
177
177
|
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Whether `rowLevel` has to be enforced entry by entry for this user - a rule
|
|
181
|
+
* is set, the user is not level 0, and buildRowLevelMatch() cannot express it
|
|
182
|
+
* as a flat match (`mode: 'reference'`, or a misconfigured/unknown rule, which
|
|
183
|
+
* checkEntryAccess() then fails closed).
|
|
184
|
+
*
|
|
185
|
+
* @param {object|null} user
|
|
186
|
+
* @param {object|null} rowLevel
|
|
187
|
+
* @returns {boolean}
|
|
188
|
+
*/
|
|
189
|
+
export function needsEntryCheck(user, rowLevel) {
|
|
190
|
+
if (!rowLevel || isAdmin(user)) return false;
|
|
191
|
+
return buildRowLevelMatch(user, rowLevel) === null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Keep only the entries `user` may access under `rowLevel` - checkEntryAccess()
|
|
196
|
+
* per entry, the same decision Actions make for a single entry. Target reads
|
|
197
|
+
* are memoised for the call, so many entries pointing at one parent cost one
|
|
198
|
+
* read of it, not one each.
|
|
199
|
+
*
|
|
200
|
+
* @param {object[]} entries
|
|
201
|
+
* @param {object|null} user
|
|
202
|
+
* @param {object|null} rowLevel
|
|
203
|
+
* @param {object} [opts]
|
|
204
|
+
* @param {Function} [opts.adapterResolver] - Optional async (slug) → adapter - for tests
|
|
205
|
+
* @returns {Promise<object[]>}
|
|
206
|
+
*/
|
|
207
|
+
export async function filterEntriesByAccess(entries, user, rowLevel, opts = {}) {
|
|
208
|
+
if (!rowLevel || isAdmin(user)) return entries;
|
|
209
|
+
const resolver = opts.adapterResolver || getAdapter;
|
|
210
|
+
const adapters = new Map();
|
|
211
|
+
const targets = new Map();
|
|
212
|
+
const memoResolver = (slug) => {
|
|
213
|
+
if (!adapters.has(slug)) {
|
|
214
|
+
adapters.set(slug, Promise.resolve().then(() => resolver(slug)).then(adapter => ({
|
|
215
|
+
get(collection, id) {
|
|
216
|
+
const key = `${collection}\u0000${id}`;
|
|
217
|
+
if (!targets.has(key)) targets.set(key, Promise.resolve().then(() => adapter.get(collection, id)));
|
|
218
|
+
return targets.get(key);
|
|
219
|
+
}
|
|
220
|
+
})));
|
|
221
|
+
}
|
|
222
|
+
return adapters.get(slug);
|
|
223
|
+
};
|
|
224
|
+
const keep = await Promise.all(entries.map(e => checkEntryAccess(e, user, rowLevel, {adapterResolver: memoResolver})));
|
|
225
|
+
return entries.filter((_, i) => keep[i]);
|
|
226
|
+
}
|