@crombolo/api-delivery 0.13.2 → 0.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/api-delivery",
3
- "version": "0.13.2",
3
+ "version": "0.14.1",
4
4
  "description": "Umbraco's Content Delivery API for crombolo: published content and media as JSON, served from the published cache.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
@@ -28,8 +28,8 @@
28
28
  "access": "public"
29
29
  },
30
30
  "dependencies": {
31
- "@crombolo/core": "0.13.2",
32
- "@crombolo/contracts": "0.13.2",
33
- "@crombolo/render": "0.13.2"
31
+ "@crombolo/core": "0.14.1",
32
+ "@crombolo/contracts": "0.14.1",
33
+ "@crombolo/render": "0.14.1"
34
34
  }
35
35
  }
package/src/handler.ts CHANGED
@@ -4,8 +4,14 @@
4
4
  * Every answer is read from memory, so it scales with the nodes that render.
5
5
  * docs/delivery-api.md.
6
6
  */
7
- import type { PublishedCache, PublishedContent, PublishedMedia } from '@crombolo/render'
8
- import { isRightToLeft } from '@crombolo/render'
7
+ import { branchAccess } from '@crombolo/core'
8
+ import type {
9
+ PublishedCache,
10
+ PublishedContent,
11
+ PublishedMedia,
12
+ SearchableText,
13
+ } from '@crombolo/render'
14
+ import { excerpt, excerptHtml, isRightToLeft } from '@crombolo/render'
9
15
  import {
10
16
  ALL_FIELDS,
11
17
  type ExpansionNode,
@@ -46,6 +52,20 @@ export interface DeliveryApiPorts {
46
52
  member(token: string): Promise<DeliveryMember | undefined>
47
53
  /** The member sign-in endpoints under `v1/security/member/`, when built. */
48
54
  security?(request: Request, action: string): Promise<Response | undefined>
55
+ /**
56
+ * Site search over published pages (story 140), behind `v2/search`: a
57
+ * Crombolo addition, which Umbraco's Delivery API has no equivalent of.
58
+ */
59
+ search?(
60
+ term: string,
61
+ options: {
62
+ culture: string
63
+ types: readonly string[]
64
+ skip: number
65
+ take: number
66
+ access: { denied: readonly number[]; allowed: readonly number[] }
67
+ },
68
+ ): Promise<{ total: number; items: Array<{ key: string; score: number; text: SearchableText }> }>
49
69
  }
50
70
 
51
71
  export interface DeliveryApi {
@@ -258,6 +278,7 @@ export function createDeliveryApi(ports: DeliveryApiPorts): DeliveryApi {
258
278
  nav,
259
279
  roots,
260
280
  member,
281
+ rules: [...loadedRules.values()],
261
282
  ctx,
262
283
  byPath,
263
284
  startItem,
@@ -451,6 +472,44 @@ export function createDeliveryApi(ports: DeliveryApiPorts): DeliveryApi {
451
472
  })
452
473
  }
453
474
 
475
+ /**
476
+ * `v2/search?term=&contentType=&skip=&take=`: published pages matching every
477
+ * word, best first, as content items with `score` and an `excerpt` in HTML.
478
+ * A members-only page is left out, and not counted, for a caller who cannot
479
+ * open it.
480
+ */
481
+ async function contentSearch(r: ContentRequest, url: URL) {
482
+ if (!ports.search) return bare(404)
483
+ const skip = intParam(url, 'skip', 0)
484
+ if (skip instanceof Response) return skip
485
+ const take = intParam(url, 'take', 10)
486
+ if (take instanceof Response) return take
487
+ const term = url.searchParams.get('term')?.trim() ?? ''
488
+ if (!term || !r.known) return json({ total: 0, items: [] })
489
+ // A member who may not sign in now opens nothing a visitor could not.
490
+ const member =
491
+ r.member?.approved && !r.member.lockedOut
492
+ ? { username: r.member.userName, groups: r.member.groups }
493
+ : undefined
494
+ const hits = await ports.search(term, {
495
+ culture: r.culture ?? '',
496
+ types: url.searchParams.getAll('contentType').filter(Boolean),
497
+ skip,
498
+ take: Math.min(take, 100),
499
+ access: branchAccess(r.rules, member),
500
+ })
501
+ const items = []
502
+ for (const hit of hits.items) {
503
+ const content = r.servable(r.nav.byKey(hit.key))
504
+ if (!content || r.ctx.access(content) !== 'allowed') continue
505
+ const body = await r.respond(content, url)
506
+ if (body instanceof Response) return body
507
+ if (body)
508
+ items.push({ ...body, score: hit.score, excerpt: excerptHtml(excerpt(hit.text, term)) })
509
+ }
510
+ return json({ total: hits.total, items })
511
+ }
512
+
454
513
  return {
455
514
  async handle(request) {
456
515
  const url = new URL(request.url)
@@ -460,6 +519,12 @@ export function createDeliveryApi(ports: DeliveryApiPorts): DeliveryApi {
460
519
  const security = /^v1\/security\/member\/(.+)$/.exec(rest)
461
520
  if (security) return ports.security?.(request, security[1] as string)
462
521
 
522
+ if (/^v2\/search\/?$/.test(rest)) {
523
+ if (request.method !== 'GET' && request.method !== 'HEAD') return bare(405)
524
+ if (!contentAccess(settings, request)) return bare(401)
525
+ return contentSearch(await contentRequest(request), url)
526
+ }
527
+
463
528
  const match = /^v2\/(content|media)(?:\/(.*))?$/.exec(rest)
464
529
  if (!match) return undefined
465
530
  if (request.method !== 'GET' && request.method !== 'HEAD') return bare(405)
package/src/protection.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Members-only content: the nearest protected ancestor governs, as on the
3
- * site, and a member is let in by name or by one of their groups. Umbraco's
4
- * `PublicAccessChecker`, over rules read once per request.
3
+ * site, and a member is let in by name or by one of their groups
4
+ * (`memberMayOpen`, shared with the site). Umbraco's `PublicAccessChecker`,
5
+ * over rules read once per request.
5
6
  */
7
+ import { memberMayOpen } from '@crombolo/core'
6
8
 
7
9
  export interface ProtectionRule {
8
10
  /** The protected node's id: its own, or the ancestor whose rule covers it. */
@@ -35,7 +37,7 @@ export function ruleFor(
35
37
  return undefined
36
38
  }
37
39
 
38
- /** What a caller may do with a node: a user name matches in any case, a group exactly. Pure. */
40
+ /** What a caller may do with a node: a user name and a group both match in any case. Pure. */
39
41
  export function accessTo(
40
42
  path: string,
41
43
  rules: ReadonlyMap<number, ProtectionRule>,
@@ -44,8 +46,7 @@ export function accessTo(
44
46
  const rule = ruleFor(path, rules)
45
47
  if (!rule) return 'allowed'
46
48
  if (!member?.approved || member.lockedOut) return 'unauthorized'
47
- const name = member.userName.toLowerCase()
48
- if (rule.memberUserNames.some((n) => n.toLowerCase() === name)) return 'allowed'
49
- if (rule.memberGroupNames.some((g) => member.groups.includes(g))) return 'allowed'
50
- return 'forbidden'
49
+ return memberMayOpen(rule, { username: member.userName, groups: member.groups })
50
+ ? 'allowed'
51
+ : 'forbidden'
51
52
  }