@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 +4 -4
- package/src/handler.ts +67 -2
- package/src/protection.ts +8 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/api-delivery",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
32
|
-
"@crombolo/contracts": "0.
|
|
33
|
-
"@crombolo/render": "0.
|
|
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
|
|
8
|
-
import {
|
|
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
|
|
4
|
-
* `
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return 'forbidden'
|
|
49
|
+
return memberMayOpen(rule, { username: member.userName, groups: member.groups })
|
|
50
|
+
? 'allowed'
|
|
51
|
+
: 'forbidden'
|
|
51
52
|
}
|