domma-cms 0.92.0 → 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.
@@ -10,6 +10,8 @@
10
10
  * public page carries them inline instead of fetching them.
11
11
  */
12
12
  import {getConfig, saveConfig} from '../config.js';
13
+ import {checkVisibility} from '../middleware/auth.js';
14
+ import {normalisePath} from '../../admin/js/lib/seo-redirects.js';
13
15
 
14
16
  /**
15
17
  * Settings, and the shape of them.
@@ -363,6 +365,115 @@ export function scorePage(page, query, words) {
363
365
  return score;
364
366
  }
365
367
 
368
+ /**
369
+ * Site-wide rules that take pages out of search whatever the viewer, which a
370
+ * page's own frontmatter does not carry - so they are resolved once per
371
+ * search (async) and handed to the sync checks below:
372
+ *
373
+ * - `offline`: pages in a DISABLED project. The public renderer 404s them
374
+ * (routes/public.js: getProjectForPage + isProjectEnabled - the same two
375
+ * calls here), so search must not read them out either.
376
+ * - `noindex`: pages the SEO screen marked noindex per URL
377
+ * (content/seo/overrides.json). The same store, keyed the same way
378
+ * (normalisePath), that the robots meta ('seo:meta') and the sitemap
379
+ * ('sitemap:entries') read - and, like them, only while SEO is switched on.
380
+ *
381
+ * @param {object[]} pages
382
+ * @param {object} [deps] - DI for tests
383
+ * @param {Function} [deps.projectOf] - async (urlPath, frontmatterProject) → slug
384
+ * @param {Function} [deps.isEnabled] - async (slug) → boolean
385
+ * @param {Function} [deps.listProjects] - async () → projects (the shortcut when none is disabled)
386
+ * @param {Function} [deps.seoOverrides] - () → {"/path": {noindex, ...}}
387
+ * @returns {Promise<{offline: Set<string>, noindex: Set<string>}>} urlPaths
388
+ */
389
+ export async function searchExclusions(pages = [], deps = {}) {
390
+ const offline = new Set();
391
+ const noindex = new Set();
392
+
393
+ const projects = deps.projectOf && deps.isEnabled ? null : await import('./projects.js');
394
+ const projectOf = deps.projectOf || projects.getProjectForPage;
395
+ const isEnabled = deps.isEnabled || projects.isProjectEnabled;
396
+ const listAll = deps.listProjects || projects?.listProjects;
397
+ // Nothing is disabled on most sites - skip resolving every page's project.
398
+ let anyDisabled = true;
399
+ if (listAll) {
400
+ try { anyDisabled = (await listAll()).some(p => p?.enabled === false); } catch { anyDisabled = true; }
401
+ }
402
+ if (anyDisabled) {
403
+ const enabled = new Map();
404
+ for (const page of pages) {
405
+ if (!page?.urlPath) continue;
406
+ try {
407
+ const slug = await projectOf(page.urlPath, page.project);
408
+ if (!enabled.has(slug)) enabled.set(slug, await isEnabled(slug));
409
+ if (!enabled.get(slug)) offline.add(page.urlPath);
410
+ } catch { /* project store unreadable - leave the page to the other checks */ }
411
+ }
412
+ }
413
+
414
+ let overrides;
415
+ if (deps.seoOverrides) {
416
+ overrides = deps.seoOverrides() || {};
417
+ } else {
418
+ // Null while SEO is switched off - and then no override reaches the
419
+ // robots meta or the sitemap either.
420
+ const {seoEngine} = await import('./seo/index.js');
421
+ overrides = seoEngine()?.store?.overrides?.() || {};
422
+ }
423
+ for (const page of pages) {
424
+ if (!page?.urlPath) continue;
425
+ const key = normalisePath(page.urlPath) || '/';
426
+ if (overrides[key]?.noindex === true) noindex.add(page.urlPath);
427
+ }
428
+ return {offline, noindex};
429
+ }
430
+
431
+ /**
432
+ * Is this page noindex? Its frontmatter (`seo.noindex`, the field core SEO
433
+ * reads for the robots meta and the sitemap), or the SEO screen's per-URL
434
+ * override when `exclusions` (searchExclusions()) is given.
435
+ *
436
+ * @param {object} page
437
+ * @param {{noindex?: Set<string>}|null} [exclusions]
438
+ * @returns {boolean}
439
+ */
440
+ export function isNoindex(page, exclusions = null) {
441
+ if (page?.seo?.noindex === true) return true;
442
+ return Boolean(exclusions?.noindex?.has(page?.urlPath));
443
+ }
444
+
445
+ /**
446
+ * Is this page in a disabled project (served as a 404)? Needs `exclusions`
447
+ * from searchExclusions(); without them nothing is.
448
+ *
449
+ * @param {object} page
450
+ * @param {{offline?: Set<string>}|null} [exclusions]
451
+ * @returns {boolean}
452
+ */
453
+ export function isOffline(page, exclusions = null) {
454
+ return Boolean(exclusions?.offline?.has(page?.urlPath));
455
+ }
456
+
457
+ /**
458
+ * May this page appear in this viewer's search results? Drafts never; pages
459
+ * in a disabled project never; pages that ask not to be indexed (frontmatter
460
+ * or the SEO screen) never; otherwise exactly when the viewer could open the
461
+ * page - checkVisibility() is the one visibility rule, the same the public
462
+ * page renderer applies, so a role-gated page (`member`, `=candidate`,
463
+ * `private`) is found only by someone it would be served to.
464
+ *
465
+ * @param {object} page
466
+ * @param {object|null} [viewer] - resolved viewer, null for anonymous
467
+ * @param {{offline?: Set<string>, noindex?: Set<string>}|null} [exclusions] - searchExclusions()
468
+ * @returns {boolean}
469
+ */
470
+ export function isSearchable(page, viewer = null, exclusions = null) {
471
+ if (!page || page.status === 'draft') return false;
472
+ if (isOffline(page, exclusions)) return false;
473
+ if (isNoindex(page, exclusions)) return false;
474
+ return checkVisibility(viewer, page.visibility);
475
+ }
476
+
366
477
  /**
367
478
  * Search a set of pages.
368
479
  *
@@ -372,19 +483,22 @@ export function scorePage(page, query, words) {
372
483
  * 500 to every query on an entire site, which reads to everyone as "search
373
484
  * finds nothing".
374
485
  *
486
+ * A result carries the title, description and a snippet of the body, so it
487
+ * is only ever built for a page the viewer could open - see isSearchable().
488
+ *
375
489
  * @param {object[]} pages
376
490
  * @param {string} query
377
- * @param {{maxResults?: number, onError?: Function}} [options]
491
+ * @param {{maxResults?: number, onError?: Function, viewer?: object|null, exclusions?: object|null}} [options]
492
+ * viewer - the resolved viewer (resolveViewer()), null for anonymous
493
+ * exclusions - searchExclusions(pages): disabled projects, SEO noindex overrides
378
494
  * @returns {object[]}
379
495
  */
380
- export function searchPages(pages, query, {maxResults = 10, onError = () => {}} = {}) {
496
+ export function searchPages(pages, query, {maxResults = 10, onError = () => {}, viewer = null, exclusions = null} = {}) {
381
497
  const words = query.toLowerCase().split(/\s+/).filter(Boolean);
382
498
  const results = [];
383
499
 
384
500
  for (const page of pages) {
385
- // Drafts and private pages are nobody's business out here.
386
- if (page.status === 'draft') continue;
387
- if (page.visibility === 'private') continue;
501
+ if (!isSearchable(page, viewer, exclusions)) continue;
388
502
 
389
503
  // One page must never be able to fail the whole search. The null-tag
390
504
  // crash was exactly this shape and the next malformed field will be
@@ -417,34 +531,45 @@ export function searchPages(pages, query, {maxResults = 10, onError = () => {}}
417
531
  // ---------------------------------------------------------------------------
418
532
 
419
533
  /**
420
- * Which pages search can return, and which it leaves out - the same rule
421
- * searchPages() applies (drafts and private pages are never results).
534
+ * Which pages an anonymous visitor's search can return, and which it leaves
535
+ * out - the same rule searchPages() applies (isSearchable() with no viewer).
536
+ * Each page left out is counted once, under the first reason that applies:
537
+ * a draft, then offline (its project is disabled), then restricted (private
538
+ * or role-gated), then noindex (frontmatter or the SEO screen). A signed-in
539
+ * visitor may find more than `searchable` - the gated pages they can open.
422
540
  *
423
541
  * @param {object[]} pages
424
- * @returns {{searchable: number, drafts: number, private: number, total: number}}
542
+ * @param {{offline?: Set<string>, noindex?: Set<string>}|null} [exclusions] - searchExclusions()
543
+ * @returns {{searchable: number, drafts: number, offline: number, restricted: number, noindex: number, total: number}}
425
544
  */
426
- export function searchCoverage(pages = []) {
545
+ export function searchCoverage(pages = [], exclusions = null) {
427
546
  let drafts = 0;
428
- let priv = 0;
547
+ let offline = 0;
548
+ let restricted = 0;
549
+ let noindex = 0;
429
550
  for (const p of pages) {
430
551
  if (p?.status === 'draft') drafts++;
431
- else if (p?.visibility === 'private') priv++;
552
+ else if (isOffline(p, exclusions)) offline++;
553
+ else if (!checkVisibility(null, p?.visibility)) restricted++;
554
+ else if (isNoindex(p, exclusions)) noindex++;
432
555
  }
433
- return {searchable: pages.length - drafts - priv, drafts, private: priv, total: pages.length};
556
+ return {searchable: pages.length - drafts - offline - restricted - noindex,
557
+ drafts, offline, restricted, noindex, total: pages.length};
434
558
  }
435
559
 
436
560
  /**
437
561
  * The sidebar badge on System > Search (its countUrl, GET /api/search/_count):
438
- * how many pages a visitor's search can find, with whether search is on at all
562
+ * how many pages an anonymous visitor's search can find, with whether search is on at all
439
563
  * and how it is reached in the popover. An inventory, not a signal - plain text
440
564
  * throughout, the popover sets it with textContent.
441
565
  *
442
566
  * @param {object} settings - normalised search settings
443
567
  * @param {object[]} pages
568
+ * @param {{offline?: Set<string>, noindex?: Set<string>}|null} [exclusions] - searchExclusions()
444
569
  */
445
- export function searchBadge(settings = SEARCH_DEFAULTS, pages = []) {
570
+ export function searchBadge(settings = SEARCH_DEFAULTS, pages = [], exclusions = null) {
446
571
  const s = normaliseSearchSettings(settings);
447
- const c = searchCoverage(pages);
572
+ const c = searchCoverage(pages, exclusions);
448
573
  const pl = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
449
574
  const trigger = s.displayMode === 'floating'
450
575
  ? `Floating button, ${s.position.replace('-', ' ')}`
@@ -462,10 +587,13 @@ export function searchBadge(settings = SEARCH_DEFAULTS, pages = []) {
462
587
  {label: 'Trigger', value: s.enabled ? trigger : '-'},
463
588
  {label: 'Keyboard shortcut', value: s.enabled && s.keyboardShortcut ? 'Ctrl+K / ⌘K' : 'Off'},
464
589
  {label: 'Searchable pages', value: String(c.searchable)},
465
- {label: 'Left out (drafts, private)', value: String(c.drafts + c.private)}
590
+ {label: 'Left out (drafts, disabled projects, private or role-gated, noindex)',
591
+ value: String(c.drafts + c.offline + c.restricted + c.noindex)}
466
592
  ],
467
593
  items: [],
468
- empty: s.enabled ? 'Visitors can search every published, public page.' : 'Search is switched off - nothing is added to public pages.'
594
+ empty: s.enabled
595
+ ? 'Visitors can search every published page they are allowed to open, except noindex pages and pages in a disabled project.'
596
+ : 'Search is switched off - nothing is added to public pages.'
469
597
  }
470
598
  };
471
599
  }
@@ -11,8 +11,8 @@
11
11
  * module reads it back.
12
12
  *
13
13
  * SCOPE - read this before reusing anything here:
14
- * The cookie is consulted ONLY by the public page renderer (GET, no side
15
- * effects). `authenticate()` in middleware/auth.js stays Bearer-only, so no
14
+ * The cookie is consulted ONLY by read-only public GETs with no side
15
+ * effects: the public page renderer and the site search (GET /api/search). `authenticate()` in middleware/auth.js stays Bearer-only, so no
16
16
  * state-changing route is reachable with ambient credentials and the CSRF
17
17
  * surface stays at zero. Do not wire `resolveViewer()` into a write path.
18
18
  */
@@ -29,7 +29,9 @@ import fs from 'fs/promises';
29
29
  import path from 'path';
30
30
  import {fileURLToPath} from 'url';
31
31
  import {v4 as uuidv4} from 'uuid';
32
- import {buildRowLevelMatch} from './rowAccess.js';
32
+ import {buildRowLevelMatch, filterEntriesByAccess, needsEntryCheck} from './rowAccess.js';
33
+ import {checkVisibility} from '../middleware/auth.js';
34
+ import {getEffectiveLevel} from './userRoles.js';
33
35
  import {executePipeline} from './viewPipeline.js';
34
36
  import * as cache from './cache/index.js';
35
37
 
@@ -293,7 +295,11 @@ export async function createView(data, userId = null) {
293
295
  block: display?.block || ''
294
296
  },
295
297
  access: {
296
- roles: access?.roles || ['admin', 'super-admin'],
298
+ // No roles named = the admin tier (effective level 0 or 1), which
299
+ // canReadView() reads from role LEVELS at request time. Roles are
300
+ // dynamic, so a default stored by name ('admin') would silently
301
+ // stop meaning anything once that role is renamed or removed.
302
+ roles: Array.isArray(access?.roles) ? access.roles : [],
297
303
  public: access?.public || false,
298
304
  rowLevel: access?.rowLevel || null
299
305
  },
@@ -385,9 +391,24 @@ async function executeMongoView(view, { page, limit, user }) {
385
391
  const pipeline = buildPipelineStages(view.pipeline?.stages || []);
386
392
 
387
393
  // Prepend row-level $match if configured
388
- const rowFilter = buildRowLevelMatch(user, view.access?.rowLevel);
394
+ const rowLevel = view.access?.rowLevel;
395
+ const rowFilter = buildRowLevelMatch(user, rowLevel);
389
396
  if (rowFilter) {
390
397
  pipeline.unshift({$match: rowFilter});
398
+ } else if (needsEntryCheck(user, rowLevel)) {
399
+ // `reference` mode is a join a flat $match cannot express. Decide each
400
+ // SOURCE entry with checkEntryAccess() - the rule Actions apply - and
401
+ // restrict the pipeline to the ids that pass. It runs before $count and
402
+ // $skip/$limit, so the total and the pages describe only rows this
403
+ // viewer may see.
404
+ const field = rowLevel.field;
405
+ const candidates = field
406
+ ? await db.collection(sourceCollection)
407
+ .find({}, {projection: {_id: 0, id: 1, [`data.${field}`]: 1}})
408
+ .toArray()
409
+ : [];
410
+ const allowed = await filterEntriesByAccess(candidates, user, rowLevel);
411
+ pipeline.unshift({$match: {id: {$in: allowed.map(d => d.id)}}});
391
412
  }
392
413
 
393
414
  // Count total without pagination
@@ -420,13 +441,19 @@ async function executeMongoView(view, { page, limit, user }) {
420
441
  async function executeFileView(view, { page, limit, user }) {
421
442
  const { getAdapter } = await import('./adapterRegistry.js');
422
443
  const adapter = await getAdapter(view.pipeline.source);
423
- const docs = await adapter.all(view.pipeline.source);
444
+ let docs = await adapter.all(view.pipeline.source);
424
445
 
425
446
  const stages = buildPipelineStages(view.pipeline?.stages || []);
426
447
 
427
- const rowFilter = buildRowLevelMatch(user, view.access?.rowLevel);
448
+ const rowLevel = view.access?.rowLevel;
449
+ const rowFilter = buildRowLevelMatch(user, rowLevel);
428
450
  if (rowFilter) {
429
451
  stages.unshift({$match: rowFilter});
452
+ } else if (needsEntryCheck(user, rowLevel)) {
453
+ // `reference` mode (a join) - checkEntryAccess() per source entry,
454
+ // before the pipeline, so `total` and the pages count only rows this
455
+ // viewer may see.
456
+ docs = await filterEntriesByAccess(docs, user, rowLevel);
430
457
  }
431
458
 
432
459
  const all = await executePipeline(docs, stages, {
@@ -438,6 +465,36 @@ async function executeFileView(view, { page, limit, user }) {
438
465
  return { results: all.slice(offset, offset + limit), total, page, limit };
439
466
  }
440
467
 
468
+ /**
469
+ * May this viewer read a saved view's rows?
470
+ *
471
+ * The ONE decision about `view.access` - the `[view]` shortcode (static and
472
+ * interactive) and `GET /api/views/:slug/public` all ask it, so a page that
473
+ * embeds a view can never show more than the view's own endpoint would.
474
+ *
475
+ * `public: true` lets everyone in. Otherwise `roles` is read with page
476
+ * visibility semantics (`checkVisibility`): a ladder per role, `=role` exact,
477
+ * level 0 always passes. A view that names no roles is the admin tier only
478
+ * (effective level 0 or 1, as `requireAdmin` reads it) - the tier createView
479
+ * defaults to - never "anyone signed in".
480
+ *
481
+ * Row-level scoping is separate: it narrows WHICH rows, and executeView
482
+ * applies it from the same viewer.
483
+ *
484
+ * @param {object|null} user - Resolved viewer (`{role, additionalRoles}`) or null
485
+ * @param {object|null} view - Saved view config
486
+ * @returns {boolean}
487
+ */
488
+ export function canReadView(user, view) {
489
+ if (!view) return false;
490
+ const access = view.access || {};
491
+ if (access.public === true) return true;
492
+ if (!user?.role) return false;
493
+ const roles = Array.isArray(access.roles) ? access.roles.filter(r => typeof r === 'string' && r) : [];
494
+ if (!roles.length) return getEffectiveLevel(user) <= 1;
495
+ return checkVisibility(user, roles);
496
+ }
497
+
441
498
  /**
442
499
  * Execute a view's pipeline and return paginated results.
443
500
  *
@@ -469,6 +526,11 @@ export async function executeViewConfig(view, {page = 1, limit = 25, user = null
469
526
  if (!view?.pipeline?.source) throw new Error('pipeline.source is required');
470
527
  validateStages(view.pipeline?.stages || []);
471
528
 
529
+ // Row-level scoping matches rows to a user. With nobody signed in there is
530
+ // no one to match, and `{'meta.createdBy': undefined}` would match every
531
+ // row that lacks an owner - so an anonymous run of a row-scoped view is empty.
532
+ if (view.access?.rowLevel && !user) return {results: [], total: 0, page, limit};
533
+
472
534
  // Dispatch on the SOURCE collection's storage adapter. A mongodb-declared
473
535
  // schema whose connection is unavailable falls back to the JS evaluator -
474
536
  // matching adapterRegistry's own file fallback.