@crombolo/data 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 +2 -2
- package/src/dialect.ts +11 -0
- package/src/index.ts +5 -0
- package/src/repositories/content-types.ts +43 -11
- package/src/repositories/documents.ts +12 -0
- package/src/repositories/external-logins.ts +89 -0
- package/src/repositories/members.ts +9 -0
- package/src/repositories/two-factor.ts +137 -0
- package/src/repositories/users.ts +8 -0
- package/src/schema/plan.ts +4 -0
- package/src/schema/site-search.ts +103 -0
- package/src/schema/two-factor.ts +47 -0
- package/src/search.ts +19 -5
- package/src/site-search.ts +237 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/data",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"description": "SQLite and Postgres storage for crombolo: schema, migrations and repositories.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Crombolo",
|
|
@@ -28,6 +28,6 @@
|
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@crombolo/core": "0.
|
|
31
|
+
"@crombolo/core": "0.14.1"
|
|
32
32
|
}
|
|
33
33
|
}
|
package/src/dialect.ts
CHANGED
|
@@ -44,6 +44,12 @@ export interface Dialect {
|
|
|
44
44
|
* prefix. Its one parameter is `fullTextQuery(terms)`. docs/search.md.
|
|
45
45
|
*/
|
|
46
46
|
readonly fullTextHits: string
|
|
47
|
+
/**
|
|
48
|
+
* Site search's matching rows of `site_search_document`, as `row_id` and
|
|
49
|
+
* `score`, weighted name 10, high 5, normal 2, low 1 on both engines. Its one
|
|
50
|
+
* parameter is `fullTextQuery(terms)`.
|
|
51
|
+
*/
|
|
52
|
+
readonly siteSearchHits: string
|
|
47
53
|
/** Terms are already folded words: letters and digits only. */
|
|
48
54
|
fullTextQuery(terms: readonly string[]): string
|
|
49
55
|
/**
|
|
@@ -90,6 +96,8 @@ export const sqliteDialect: Dialect = {
|
|
|
90
96
|
FROM search_document_fts WHERE search_document_fts MATCH ? LIMIT -1) f
|
|
91
97
|
JOIN search_document sd ON sd.id = f.id GROUP BY sd.node_id`,
|
|
92
98
|
fullTextQuery: (terms) => terms.map((term) => `"${term}"*`).join(' AND '),
|
|
99
|
+
siteSearchHits: `SELECT rowid AS row_id, -bm25(site_search_document_fts, 10.0, 5.0, 2.0, 1.0) AS score
|
|
100
|
+
FROM site_search_document_fts WHERE site_search_document_fts MATCH ? LIMIT -1`,
|
|
93
101
|
syncIdentity: () => undefined,
|
|
94
102
|
}
|
|
95
103
|
|
|
@@ -126,6 +134,9 @@ export const postgresDialect: Dialect = {
|
|
|
126
134
|
FROM search_document sd, to_tsquery('simple', ?) q
|
|
127
135
|
WHERE sd.terms @@ q GROUP BY sd.node_id`,
|
|
128
136
|
fullTextQuery: (terms) => terms.map((term) => `${term}:*`).join(' & '),
|
|
137
|
+
// ts_rank's weights run D, C, B, A: the same 1, 2, 5, 10 as SQLite's bm25 weights.
|
|
138
|
+
siteSearchHits: `SELECT s.id AS row_id, ts_rank('{0.1, 0.2, 0.5, 1.0}', s.terms, q) AS score
|
|
139
|
+
FROM site_search_document s, to_tsquery('simple', ?) q WHERE s.terms @@ q`,
|
|
129
140
|
syncIdentity: (table) =>
|
|
130
141
|
`SELECT setval(pg_get_serial_sequence('${table}', 'id'), (SELECT COALESCE(MAX(id), 1) FROM ${table}))`,
|
|
131
142
|
}
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ export * from './repositories/dictionary.ts'
|
|
|
21
21
|
export * from './repositories/documents.ts'
|
|
22
22
|
export * from './repositories/domains.ts'
|
|
23
23
|
export * from './repositories/dynamic-roots.ts'
|
|
24
|
+
export * from './repositories/external-logins.ts'
|
|
24
25
|
export * from './repositories/folders.ts'
|
|
25
26
|
export * from './repositories/form-entries.ts'
|
|
26
27
|
export * from './repositories/form-workflows.ts'
|
|
@@ -36,6 +37,7 @@ export * from './repositories/tags.ts'
|
|
|
36
37
|
export * from './repositories/templates.ts'
|
|
37
38
|
export * from './repositories/transfer-answers.ts'
|
|
38
39
|
export * from './repositories/transfer-runs.ts'
|
|
40
|
+
export * from './repositories/two-factor.ts'
|
|
39
41
|
export * from './repositories/user-data.ts'
|
|
40
42
|
export * from './repositories/users.ts'
|
|
41
43
|
export * from './repositories/webhooks.ts'
|
|
@@ -69,9 +71,11 @@ export * from './schema/rich-text-ui.ts'
|
|
|
69
71
|
export * from './schema/search-index.ts'
|
|
70
72
|
export * from './schema/seed.ts'
|
|
71
73
|
export * from './schema/since-version.ts'
|
|
74
|
+
export * from './schema/site-search.ts'
|
|
72
75
|
export * from './schema/transfer-answers.ts'
|
|
73
76
|
export * from './schema/transfer-run-package.ts'
|
|
74
77
|
export * from './schema/transfer-runs.ts'
|
|
78
|
+
export * from './schema/two-factor.ts'
|
|
75
79
|
export * from './schema/upgrade-report.ts'
|
|
76
80
|
export * from './schema/users-localisation.ts'
|
|
77
81
|
export * from './schema/values.ts'
|
|
@@ -81,6 +85,7 @@ export * from './schema-state.ts'
|
|
|
81
85
|
export * from './search.ts'
|
|
82
86
|
export * from './search-text.ts'
|
|
83
87
|
export * from './setup.ts'
|
|
88
|
+
export * from './site-search.ts'
|
|
84
89
|
export * from './tree-invariants.ts'
|
|
85
90
|
export * from './url-options.ts'
|
|
86
91
|
export * from './workflow-store.ts'
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type PropertyGroupModel,
|
|
19
19
|
PropertyGroupType,
|
|
20
20
|
type PropertyTypeModel,
|
|
21
|
+
type SiteSearchLevel,
|
|
21
22
|
SystemNodes,
|
|
22
23
|
variationFlags,
|
|
23
24
|
variesByCulture,
|
|
@@ -32,19 +33,36 @@ import { NodeRepository } from './nodes.ts'
|
|
|
32
33
|
|
|
33
34
|
const GROUP_TYPE_NAMES = ['Group', 'Tab'] as const
|
|
34
35
|
|
|
35
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* The live properties of a type, by alias, variation, storage and search
|
|
38
|
+
* settings, and whether the type is in site search: what its items are indexed by.
|
|
39
|
+
*/
|
|
36
40
|
async function propertySignature(db: Db, contentTypeNodeId: number): Promise<string> {
|
|
37
41
|
const rows = await db.query<Record<string, unknown>>(
|
|
38
|
-
`SELECT p.id, p.alias, p.variations, p.data_type_id, p.searchable FROM property_type p
|
|
42
|
+
`SELECT p.id, p.alias, p.variations, p.data_type_id, p.searchable, p.site_search FROM property_type p
|
|
39
43
|
WHERE p.content_type_id = ? AND p.retired_at IS NULL ORDER BY p.id`,
|
|
40
44
|
[contentTypeNodeId],
|
|
41
45
|
)
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
const [type] = await db.query<{ site_search: unknown }>(
|
|
47
|
+
'SELECT site_search FROM content_type WHERE node_id = ?',
|
|
48
|
+
[contentTypeNodeId],
|
|
49
|
+
)
|
|
50
|
+
return [
|
|
51
|
+
`site:${type ? fromDbBool(type.site_search) : true}`,
|
|
52
|
+
...rows.map(
|
|
44
53
|
(row) =>
|
|
45
|
-
`${row.id}:${row.alias}:${row.variations}:${row.data_type_id}:${fromDbBool(row.searchable)}`,
|
|
46
|
-
)
|
|
47
|
-
|
|
54
|
+
`${row.id}:${row.alias}:${row.variations}:${row.data_type_id}:${fromDbBool(row.searchable)}:${row.site_search}`,
|
|
55
|
+
),
|
|
56
|
+
].join('|')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** How a property's site-search setting is stored: its level, or `off`. */
|
|
60
|
+
function siteSearchColumn(value: SiteSearchLevel | false | undefined): string {
|
|
61
|
+
return value === false ? 'off' : (value ?? 'normal')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function siteSearchFromColumn(value: unknown): SiteSearchLevel | false {
|
|
65
|
+
return value === 'off' ? false : value === 'high' || value === 'low' ? value : 'normal'
|
|
48
66
|
}
|
|
49
67
|
|
|
50
68
|
export interface SaveContentTypeOptions {
|
|
@@ -295,6 +313,12 @@ export class ContentTypeRepository {
|
|
|
295
313
|
[nodeId, ...typeValues, options.sinceStateId ?? null],
|
|
296
314
|
)
|
|
297
315
|
}
|
|
316
|
+
// As for a property's flags: only a schema sync says.
|
|
317
|
+
if (aggregate.siteSearch !== undefined)
|
|
318
|
+
await tx.exec('UPDATE content_type SET site_search = ? WHERE node_id = ?', [
|
|
319
|
+
bool(aggregate.siteSearch),
|
|
320
|
+
nodeId,
|
|
321
|
+
])
|
|
298
322
|
|
|
299
323
|
// ---- groups: upsert by key
|
|
300
324
|
const groupIdByKey = new Map<string, number>()
|
|
@@ -386,18 +410,24 @@ export class ContentTypeRepository {
|
|
|
386
410
|
bool(property.searchable),
|
|
387
411
|
Number(found[0].id),
|
|
388
412
|
])
|
|
413
|
+
if (property.siteSearch !== undefined)
|
|
414
|
+
await tx.exec('UPDATE property_type SET site_search = ? WHERE id = ?', [
|
|
415
|
+
siteSearchColumn(property.siteSearch),
|
|
416
|
+
Number(found[0].id),
|
|
417
|
+
])
|
|
389
418
|
} else {
|
|
390
419
|
await tx.exec(
|
|
391
420
|
`INSERT INTO property_type
|
|
392
421
|
(unique_id, content_type_id, data_type_id, property_type_group_id, alias, name, description, sort_order,
|
|
393
|
-
mandatory, mandatory_message, validation_reg_exp, validation_reg_exp_message, label_on_top, variations, since_version, since_state_id, searchable)
|
|
394
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
422
|
+
mandatory, mandatory_message, validation_reg_exp, validation_reg_exp_message, label_on_top, variations, since_version, since_state_id, searchable, site_search)
|
|
423
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
395
424
|
[
|
|
396
425
|
key,
|
|
397
426
|
nodeId,
|
|
398
427
|
...columns,
|
|
399
428
|
options.sinceStateId ?? null,
|
|
400
429
|
bool(property.searchable !== false),
|
|
430
|
+
siteSearchColumn(property.siteSearch),
|
|
401
431
|
],
|
|
402
432
|
)
|
|
403
433
|
}
|
|
@@ -747,7 +777,7 @@ export class ContentTypeRepository {
|
|
|
747
777
|
const typeRows = await this.#db.query(
|
|
748
778
|
`SELECT ct.alias, ct.icon, ct.description, ct.list_view, ct.is_element, ct.allow_in_library, ct.allow_at_root, ct.variations,
|
|
749
779
|
ct.prevent_cleanup, ct.keep_all_versions_newer_than_days, ct.keep_latest_version_per_day_for_days,
|
|
750
|
-
ct.since_version, ss.version AS since_v, ss.revision AS since_r
|
|
780
|
+
ct.since_version, ct.site_search, ss.version AS since_v, ss.revision AS since_r
|
|
751
781
|
FROM content_type ct LEFT JOIN schema_state ss ON ss.id = ct.since_state_id WHERE ct.node_id = ?`,
|
|
752
782
|
[nodeId],
|
|
753
783
|
)
|
|
@@ -775,7 +805,7 @@ export class ContentTypeRepository {
|
|
|
775
805
|
p.validation_reg_exp, p.validation_reg_exp_message, p.label_on_top, p.variations,
|
|
776
806
|
dtn.unique_id AS data_type_key, g.unique_id AS container_key,
|
|
777
807
|
p.since_version, ss.version AS since_v, ss.revision AS since_r,
|
|
778
|
-
m.member_can_edit, m.member_can_view, m.is_sensitive, p.searchable
|
|
808
|
+
m.member_can_edit, m.member_can_view, m.is_sensitive, p.searchable, p.site_search
|
|
779
809
|
FROM property_type p
|
|
780
810
|
JOIN node dtn ON dtn.id = p.data_type_id
|
|
781
811
|
LEFT JOIN property_type_group g ON g.id = p.property_type_group_id
|
|
@@ -803,6 +833,7 @@ export class ContentTypeRepository {
|
|
|
803
833
|
sinceVersion: (row.since_version as string | null) ?? null,
|
|
804
834
|
pending: this.#pending(row),
|
|
805
835
|
...(fromDbBool(row.searchable) ? {} : { searchable: false }),
|
|
836
|
+
siteSearch: siteSearchFromColumn(row.site_search),
|
|
806
837
|
...(this.#kind === 'member'
|
|
807
838
|
? {
|
|
808
839
|
memberCanEdit: fromDbBool(row.member_can_edit),
|
|
@@ -839,6 +870,7 @@ export class ContentTypeRepository {
|
|
|
839
870
|
variesBySegment: variesBySegment(flags),
|
|
840
871
|
isElement: fromDbBool(type.is_element),
|
|
841
872
|
allowedInLibrary: fromDbBool(type.allow_in_library),
|
|
873
|
+
siteSearch: fromDbBool(type.site_search),
|
|
842
874
|
collectionKey: type.list_view ? normaliseUuid(String(type.list_view)) : null,
|
|
843
875
|
cleanup: {
|
|
844
876
|
preventCleanup: fromDbBool(type.prevent_cleanup),
|
|
@@ -967,6 +967,8 @@ export class DocumentRepository {
|
|
|
967
967
|
node.id,
|
|
968
968
|
])
|
|
969
969
|
}
|
|
970
|
+
// Site search holds published values only; what this took offline leaves it.
|
|
971
|
+
await queueForSearch(tx, [node.id])
|
|
970
972
|
await repo.#instructContent(node.key)
|
|
971
973
|
return repo.#load(node)
|
|
972
974
|
})
|
|
@@ -1060,6 +1062,13 @@ export class DocumentRepository {
|
|
|
1060
1062
|
const node = await repo.nodes.byKey(key)
|
|
1061
1063
|
if (!node) return false
|
|
1062
1064
|
if (node.parentId === bin) return true
|
|
1065
|
+
// Read before the move, while the branch's paths are still under the node's own.
|
|
1066
|
+
const branch = (
|
|
1067
|
+
await tx.query<{ id: number }>('SELECT id FROM node WHERE id = ? OR path LIKE ?', [
|
|
1068
|
+
node.id,
|
|
1069
|
+
`${node.path},%`,
|
|
1070
|
+
])
|
|
1071
|
+
).map((row) => Number(row.id))
|
|
1063
1072
|
const t = tx.dialect.boolValue(true)
|
|
1064
1073
|
const f = tx.dialect.boolValue(false)
|
|
1065
1074
|
await tx.exec('UPDATE document SET original_parent_id = ? WHERE node_id = ?', [
|
|
@@ -1081,6 +1090,9 @@ export class DocumentRepository {
|
|
|
1081
1090
|
await repo.#nextSortOrder(bin, node.id),
|
|
1082
1091
|
node.id,
|
|
1083
1092
|
])
|
|
1093
|
+
// The branch is no longer published: other nodes drop it, and so does site search.
|
|
1094
|
+
await queueForSearch(tx, branch)
|
|
1095
|
+
await repo.#instructContent(node.key)
|
|
1084
1096
|
return true
|
|
1085
1097
|
}),
|
|
1086
1098
|
)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The accounts at external identity providers linked to local members and
|
|
3
|
+
* users (story 031). `external_login` is polymorphic on `user_or_member_key`, as
|
|
4
|
+
* Umbraco's is; the login provider says which audience it belongs to, as
|
|
5
|
+
* Umbraco's scheme names do — `UmbracoMembers.{id}` for members, `Umbraco.{id}`
|
|
6
|
+
* for users — so one provider configured for both never mixes them up.
|
|
7
|
+
*/
|
|
8
|
+
import { normaliseUuid } from '@crombolo/core'
|
|
9
|
+
import type { Db } from '../database.ts'
|
|
10
|
+
import { DbDate } from '../dialect.ts'
|
|
11
|
+
|
|
12
|
+
export type ExternalLoginAudience = 'member' | 'user'
|
|
13
|
+
|
|
14
|
+
/** The login provider a provider id is recorded under, for an audience. */
|
|
15
|
+
export function externalLoginProvider(audience: ExternalLoginAudience, providerId: string): string {
|
|
16
|
+
return `${audience === 'member' ? 'UmbracoMembers' : 'Umbraco'}.${providerId}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ExternalLogin {
|
|
20
|
+
/** The member's or user's key. */
|
|
21
|
+
ownerKey: string
|
|
22
|
+
loginProvider: string
|
|
23
|
+
/** The provider's id for the account: its `sub`. */
|
|
24
|
+
providerKey: string
|
|
25
|
+
createDate: Date
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const toLogin = (row: Record<string, unknown>): ExternalLogin => ({
|
|
29
|
+
ownerKey: normaliseUuid(String(row.user_or_member_key)),
|
|
30
|
+
loginProvider: String(row.login_provider),
|
|
31
|
+
providerKey: String(row.provider_key),
|
|
32
|
+
createDate: DbDate.fromDb(row.create_date) ?? new Date(0),
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
export class ExternalLoginRepository {
|
|
36
|
+
#db: Db
|
|
37
|
+
|
|
38
|
+
constructor(db: Db) {
|
|
39
|
+
this.#db = db
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Who an account at a provider is linked to, if anyone. */
|
|
43
|
+
async find(loginProvider: string, providerKey: string): Promise<ExternalLogin | undefined> {
|
|
44
|
+
const rows = await this.#db.query(
|
|
45
|
+
'SELECT * FROM external_login WHERE login_provider = ? AND provider_key = ?',
|
|
46
|
+
[loginProvider, providerKey],
|
|
47
|
+
)
|
|
48
|
+
return rows[0] ? toLogin(rows[0]) : undefined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Every provider account linked to a member or user. */
|
|
52
|
+
async forOwner(ownerKey: string): Promise<ExternalLogin[]> {
|
|
53
|
+
const rows = await this.#db.query(
|
|
54
|
+
'SELECT * FROM external_login WHERE user_or_member_key = ? ORDER BY login_provider',
|
|
55
|
+
[normaliseUuid(ownerKey)],
|
|
56
|
+
)
|
|
57
|
+
return rows.map(toLogin)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Links a provider account to a member or user, replacing an earlier link to that provider. */
|
|
61
|
+
async link(ownerKey: string, loginProvider: string, providerKey: string): Promise<void> {
|
|
62
|
+
await this.#db.transaction(async (tx) => {
|
|
63
|
+
await removeLink(tx, ownerKey, loginProvider)
|
|
64
|
+
await tx.exec(
|
|
65
|
+
`INSERT INTO external_login (user_or_member_key, login_provider, provider_key, create_date)
|
|
66
|
+
VALUES (?, ?, ?, ?)`,
|
|
67
|
+
[normaliseUuid(ownerKey), loginProvider, providerKey, DbDate.toDb(new Date())],
|
|
68
|
+
)
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async unlink(ownerKey: string, loginProvider: string): Promise<void> {
|
|
73
|
+
await this.#db.transaction((tx) => removeLink(tx, ownerKey, loginProvider))
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A link and the tokens kept for it, which reference it. */
|
|
78
|
+
async function removeLink(tx: Db, ownerKey: string, loginProvider: string): Promise<void> {
|
|
79
|
+
const params = [loginProvider, normaliseUuid(ownerKey)]
|
|
80
|
+
await tx.exec(
|
|
81
|
+
`DELETE FROM external_login_token WHERE external_login_id IN
|
|
82
|
+
(SELECT id FROM external_login WHERE login_provider = ? AND user_or_member_key = ?)`,
|
|
83
|
+
params,
|
|
84
|
+
)
|
|
85
|
+
await tx.exec(
|
|
86
|
+
'DELETE FROM external_login WHERE login_provider = ? AND user_or_member_key = ?',
|
|
87
|
+
params,
|
|
88
|
+
)
|
|
89
|
+
}
|
|
@@ -204,6 +204,13 @@ export class MemberRepository {
|
|
|
204
204
|
)
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
/** Replaces a member's groups, by group key; what an identity provider's group sync writes. */
|
|
208
|
+
async setGroups(key: string, groupKeys: readonly string[]): Promise<void> {
|
|
209
|
+
const node = await this.#nodes.byKey(key)
|
|
210
|
+
if (!node || node.objectType !== ObjectTypes.Member) return
|
|
211
|
+
await this.#db.transaction(() => this.#writeGroups(node.id, groupKeys))
|
|
212
|
+
}
|
|
213
|
+
|
|
207
214
|
async #writeGroups(nodeId: number, groupKeys: readonly string[]): Promise<void> {
|
|
208
215
|
await this.#db.exec('DELETE FROM member_group_member WHERE member_id = ?', [nodeId])
|
|
209
216
|
for (const key of groupKeys) {
|
|
@@ -352,6 +359,8 @@ export class MemberRepository {
|
|
|
352
359
|
[node.key],
|
|
353
360
|
)
|
|
354
361
|
await this.#db.exec('DELETE FROM external_login WHERE user_or_member_key = ?', [node.key])
|
|
362
|
+
await this.#db.exec('DELETE FROM two_factor_login WHERE owner_key = ?', [node.key])
|
|
363
|
+
await this.#db.exec('DELETE FROM two_factor_recovery_code WHERE owner_key = ?', [node.key])
|
|
355
364
|
await this.#db.exec('DELETE FROM member WHERE node_id = ?', [node.id])
|
|
356
365
|
return this.content.delete(key)
|
|
357
366
|
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The authenticators and recovery codes of users and members (story 032). The
|
|
3
|
+
* secret arrives here already sealed and leaves still sealed: sealing is the
|
|
4
|
+
* server's, with `CROMBOLO_SECRET_KEY`, so this layer never holds a key.
|
|
5
|
+
*/
|
|
6
|
+
import { normaliseUuid } from '@crombolo/core'
|
|
7
|
+
import type { Db } from '../database.ts'
|
|
8
|
+
import { DbDate } from '../dialect.ts'
|
|
9
|
+
|
|
10
|
+
export interface TwoFactorLogin {
|
|
11
|
+
ownerKey: string
|
|
12
|
+
providerName: string
|
|
13
|
+
sealedSecret: string
|
|
14
|
+
lastUsedStep: number | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class TwoFactorRepository {
|
|
18
|
+
#db: Db
|
|
19
|
+
|
|
20
|
+
constructor(db: Db) {
|
|
21
|
+
this.#db = db
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The providers an owner has turned on. */
|
|
25
|
+
async providers(ownerKey: string): Promise<string[]> {
|
|
26
|
+
const rows = await this.#db.query<{ provider_name: string }>(
|
|
27
|
+
'SELECT provider_name FROM two_factor_login WHERE owner_key = ? ORDER BY provider_name',
|
|
28
|
+
[normaliseUuid(ownerKey)],
|
|
29
|
+
)
|
|
30
|
+
return rows.map((row) => String(row.provider_name))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Which of these owners have any provider on, for list views. */
|
|
34
|
+
async enabledAmong(ownerKeys: readonly string[]): Promise<Set<string>> {
|
|
35
|
+
if (ownerKeys.length === 0) return new Set()
|
|
36
|
+
const keys = ownerKeys.map(normaliseUuid)
|
|
37
|
+
const rows = await this.#db.query<{ owner_key: string }>(
|
|
38
|
+
`SELECT DISTINCT owner_key FROM two_factor_login WHERE owner_key IN (${keys.map(() => '?').join(', ')})`,
|
|
39
|
+
keys,
|
|
40
|
+
)
|
|
41
|
+
return new Set(rows.map((row) => normaliseUuid(String(row.owner_key))))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async get(ownerKey: string, providerName: string): Promise<TwoFactorLogin | undefined> {
|
|
45
|
+
const rows = await this.#db.query<Record<string, unknown>>(
|
|
46
|
+
'SELECT * FROM two_factor_login WHERE owner_key = ? AND provider_name = ?',
|
|
47
|
+
[normaliseUuid(ownerKey), providerName],
|
|
48
|
+
)
|
|
49
|
+
const row = rows[0]
|
|
50
|
+
if (!row) return undefined
|
|
51
|
+
return {
|
|
52
|
+
ownerKey: normaliseUuid(String(row.owner_key)),
|
|
53
|
+
providerName: String(row.provider_name),
|
|
54
|
+
sealedSecret: String(row.secret),
|
|
55
|
+
lastUsedStep:
|
|
56
|
+
row.last_used_step === null || row.last_used_step === undefined
|
|
57
|
+
? null
|
|
58
|
+
: Number(row.last_used_step),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Turns a provider on, with its recovery codes' hashes; replaces any earlier setup. */
|
|
63
|
+
async enable(
|
|
64
|
+
ownerKey: string,
|
|
65
|
+
providerName: string,
|
|
66
|
+
sealedSecret: string,
|
|
67
|
+
acceptedStep: number,
|
|
68
|
+
recoveryHashes: readonly string[],
|
|
69
|
+
): Promise<void> {
|
|
70
|
+
const owner = normaliseUuid(ownerKey)
|
|
71
|
+
await this.#db.transaction(async (tx) => {
|
|
72
|
+
await tx.exec('DELETE FROM two_factor_login WHERE owner_key = ? AND provider_name = ?', [
|
|
73
|
+
owner,
|
|
74
|
+
providerName,
|
|
75
|
+
])
|
|
76
|
+
await tx.exec(
|
|
77
|
+
`INSERT INTO two_factor_login (owner_key, provider_name, secret, last_used_step, create_date)
|
|
78
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
79
|
+
[owner, providerName, sealedSecret, acceptedStep, DbDate.toDb(new Date())],
|
|
80
|
+
)
|
|
81
|
+
await tx.exec('DELETE FROM two_factor_recovery_code WHERE owner_key = ?', [owner])
|
|
82
|
+
for (const hash of recoveryHashes)
|
|
83
|
+
await tx.exec('INSERT INTO two_factor_recovery_code (owner_key, code_hash) VALUES (?, ?)', [
|
|
84
|
+
owner,
|
|
85
|
+
hash,
|
|
86
|
+
])
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Records a step a code was accepted for, refusing one at or before the last; true when recorded. */
|
|
91
|
+
async useStep(ownerKey: string, providerName: string, step: number): Promise<boolean> {
|
|
92
|
+
const rows = await this.#db.query<{ owner_key: string }>(
|
|
93
|
+
`UPDATE two_factor_login SET last_used_step = ?
|
|
94
|
+
WHERE owner_key = ? AND provider_name = ? AND (last_used_step IS NULL OR last_used_step < ?)
|
|
95
|
+
RETURNING owner_key`,
|
|
96
|
+
[step, normaliseUuid(ownerKey), providerName, step],
|
|
97
|
+
)
|
|
98
|
+
return rows.length > 0
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Spends a recovery code by its hash; true when it was there and unused. */
|
|
102
|
+
async useRecoveryCode(ownerKey: string, hash: string): Promise<boolean> {
|
|
103
|
+
const rows = await this.#db.query<{ id: number }>(
|
|
104
|
+
`UPDATE two_factor_recovery_code SET used_date = ?
|
|
105
|
+
WHERE owner_key = ? AND code_hash = ? AND used_date IS NULL RETURNING id`,
|
|
106
|
+
[DbDate.toDb(new Date()), normaliseUuid(ownerKey), hash],
|
|
107
|
+
)
|
|
108
|
+
return rows.length > 0
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async recoveryCodesLeft(ownerKey: string): Promise<number> {
|
|
112
|
+
const rows = await this.#db.query<{ n: number }>(
|
|
113
|
+
'SELECT COUNT(*) AS n FROM two_factor_recovery_code WHERE owner_key = ? AND used_date IS NULL',
|
|
114
|
+
[normaliseUuid(ownerKey)],
|
|
115
|
+
)
|
|
116
|
+
return Number(rows[0]?.n ?? 0)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Turns a provider off, or every provider when none is named, with the recovery codes once none is left. */
|
|
120
|
+
async disable(ownerKey: string, providerName?: string): Promise<void> {
|
|
121
|
+
const owner = normaliseUuid(ownerKey)
|
|
122
|
+
await this.#db.transaction(async (tx) => {
|
|
123
|
+
if (providerName)
|
|
124
|
+
await tx.exec('DELETE FROM two_factor_login WHERE owner_key = ? AND provider_name = ?', [
|
|
125
|
+
owner,
|
|
126
|
+
providerName,
|
|
127
|
+
])
|
|
128
|
+
else await tx.exec('DELETE FROM two_factor_login WHERE owner_key = ?', [owner])
|
|
129
|
+
const left = await tx.query<{ n: number }>(
|
|
130
|
+
'SELECT COUNT(*) AS n FROM two_factor_login WHERE owner_key = ?',
|
|
131
|
+
[owner],
|
|
132
|
+
)
|
|
133
|
+
if (Number(left[0]?.n ?? 0) === 0)
|
|
134
|
+
await tx.exec('DELETE FROM two_factor_recovery_code WHERE owner_key = ?', [owner])
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -464,6 +464,14 @@ export class UserRepository {
|
|
|
464
464
|
'user_login_session',
|
|
465
465
|
])
|
|
466
466
|
await tx.exec(`DELETE FROM ${table} WHERE user_id = ?`, [user.id])
|
|
467
|
+
// Their provider accounts too, which are keyed by the user rather than the row.
|
|
468
|
+
await tx.exec(
|
|
469
|
+
'DELETE FROM external_login_token WHERE external_login_id IN (SELECT id FROM external_login WHERE user_or_member_key = ?)',
|
|
470
|
+
[user.key],
|
|
471
|
+
)
|
|
472
|
+
await tx.exec('DELETE FROM external_login WHERE user_or_member_key = ?', [user.key])
|
|
473
|
+
await tx.exec('DELETE FROM two_factor_login WHERE owner_key = ?', [user.key])
|
|
474
|
+
await tx.exec('DELETE FROM two_factor_recovery_code WHERE owner_key = ?', [user.key])
|
|
467
475
|
await tx.exec('DELETE FROM user_account WHERE id = ?', [user.id])
|
|
468
476
|
})
|
|
469
477
|
}
|
package/src/schema/plan.ts
CHANGED
|
@@ -44,9 +44,11 @@ import { richTextUiMigration } from './rich-text-ui.ts'
|
|
|
44
44
|
import { searchIndexMigration } from './search-index.ts'
|
|
45
45
|
import { serverRoleMigration } from './server-role.ts'
|
|
46
46
|
import { sinceVersionMigration } from './since-version.ts'
|
|
47
|
+
import { siteSearchMigration } from './site-search.ts'
|
|
47
48
|
import { transferAnswersMigration } from './transfer-answers.ts'
|
|
48
49
|
import { transferRunPackageMigration } from './transfer-run-package.ts'
|
|
49
50
|
import { transferRunsMigration } from './transfer-runs.ts'
|
|
51
|
+
import { twoFactorMigration } from './two-factor.ts'
|
|
50
52
|
import { upgradeReportMigration } from './upgrade-report.ts'
|
|
51
53
|
import { usersLocalisationMigration } from './users-localisation.ts'
|
|
52
54
|
import { valuesMigration } from './values.ts'
|
|
@@ -91,4 +93,6 @@ export const cromboloPlan = new MigrationPlan([
|
|
|
91
93
|
transferAnswersMigration,
|
|
92
94
|
deliveryMemberTokensMigration,
|
|
93
95
|
approvalsMigration,
|
|
96
|
+
siteSearchMigration,
|
|
97
|
+
twoFactorMigration,
|
|
94
98
|
])
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration 039 — site search for visitors (story 140). docs/search.md.
|
|
3
|
+
*
|
|
4
|
+
* `site_search_document` holds one row per page and culture, from its
|
|
5
|
+
* published values only: the name, and the text of its properties in three
|
|
6
|
+
* columns by weight, each folded as backoffice search folds. `text` keeps the
|
|
7
|
+
* same text as written, for excerpts. SQLite matches through an FTS5 table kept
|
|
8
|
+
* in step by triggers, Postgres through a generated `tsvector` whose four
|
|
9
|
+
* weights are the four columns.
|
|
10
|
+
*
|
|
11
|
+
* `site_search` on a property says which column its text goes in, or `off`;
|
|
12
|
+
* on a content type, false leaves its pages out. Both default to searchable.
|
|
13
|
+
* Every page is queued, which is the backfill.
|
|
14
|
+
*/
|
|
15
|
+
import { ObjectTypes } from '@crombolo/core'
|
|
16
|
+
import type { Db } from '../database.ts'
|
|
17
|
+
import type { Migration } from '../migrations.ts'
|
|
18
|
+
import { schemaOps } from '../schema-ops.ts'
|
|
19
|
+
import { STATE_APPROVALS } from './approvals.ts'
|
|
20
|
+
|
|
21
|
+
export const STATE_SITE_SEARCH = '{9a5d1b4e-0039-4c73-8d18-4e0a3c1f7d59}'
|
|
22
|
+
|
|
23
|
+
const COLUMNS = ['name', 'high', 'normal', 'low'] as const
|
|
24
|
+
|
|
25
|
+
export const siteSearchMigration: Migration = {
|
|
26
|
+
from: STATE_APPROVALS,
|
|
27
|
+
to: STATE_SITE_SEARCH,
|
|
28
|
+
name: 'AddSiteSearch',
|
|
29
|
+
kind: 'expand',
|
|
30
|
+
release: '0.14.0',
|
|
31
|
+
async up(db: Db) {
|
|
32
|
+
const t = db.dialect.types
|
|
33
|
+
const postgres = db.dialect.name === 'postgres'
|
|
34
|
+
const ops = schemaOps(db)
|
|
35
|
+
await ops.addColumn('property_type', 'site_search', `${t.varchar(8)} NOT NULL DEFAULT 'normal'`)
|
|
36
|
+
await ops.addColumn(
|
|
37
|
+
'content_type',
|
|
38
|
+
'site_search',
|
|
39
|
+
`${t.boolean} NOT NULL DEFAULT ${postgres ? 'TRUE' : '1'}`,
|
|
40
|
+
)
|
|
41
|
+
await db.exec(
|
|
42
|
+
`CREATE TABLE site_search_document (
|
|
43
|
+
id ${t.identity},
|
|
44
|
+
node_id ${t.integer} NOT NULL REFERENCES node (id) ON DELETE CASCADE,
|
|
45
|
+
culture ${t.varchar(14)} NOT NULL,
|
|
46
|
+
name ${t.text} NOT NULL,
|
|
47
|
+
high ${t.text} NOT NULL,
|
|
48
|
+
normal ${t.text} NOT NULL,
|
|
49
|
+
low ${t.text} NOT NULL,
|
|
50
|
+
text ${t.json} NOT NULL,
|
|
51
|
+
indexed_at ${t.timestamp} NOT NULL${
|
|
52
|
+
postgres
|
|
53
|
+
? `,
|
|
54
|
+
terms tsvector GENERATED ALWAYS AS (
|
|
55
|
+
setweight(to_tsvector('simple', name), 'A') || setweight(to_tsvector('simple', high), 'B') ||
|
|
56
|
+
setweight(to_tsvector('simple', normal), 'C') || setweight(to_tsvector('simple', low), 'D')
|
|
57
|
+
) STORED`
|
|
58
|
+
: ''
|
|
59
|
+
},
|
|
60
|
+
UNIQUE (node_id, culture)
|
|
61
|
+
)`,
|
|
62
|
+
)
|
|
63
|
+
if (postgres) {
|
|
64
|
+
await db.exec(
|
|
65
|
+
'CREATE INDEX ix_site_search_document_terms ON site_search_document USING GIN (terms)',
|
|
66
|
+
)
|
|
67
|
+
} else {
|
|
68
|
+
const columns = COLUMNS.join(', ')
|
|
69
|
+
const fromNew = COLUMNS.map((c) => `new.${c}`).join(', ')
|
|
70
|
+
const fromOld = COLUMNS.map((c) => `old.${c}`).join(', ')
|
|
71
|
+
await db.exec(
|
|
72
|
+
`CREATE VIRTUAL TABLE site_search_document_fts USING fts5(
|
|
73
|
+
${columns}, content='site_search_document', content_rowid='id',
|
|
74
|
+
tokenize='unicode61 remove_diacritics 2'
|
|
75
|
+
)`,
|
|
76
|
+
)
|
|
77
|
+
await db.exec(
|
|
78
|
+
`CREATE TRIGGER site_search_document_ai AFTER INSERT ON site_search_document BEGIN
|
|
79
|
+
INSERT INTO site_search_document_fts (rowid, ${columns}) VALUES (new.id, ${fromNew});
|
|
80
|
+
END`,
|
|
81
|
+
)
|
|
82
|
+
await db.exec(
|
|
83
|
+
`CREATE TRIGGER site_search_document_ad AFTER DELETE ON site_search_document BEGIN
|
|
84
|
+
INSERT INTO site_search_document_fts (site_search_document_fts, rowid, ${columns})
|
|
85
|
+
VALUES ('delete', old.id, ${fromOld});
|
|
86
|
+
END`,
|
|
87
|
+
)
|
|
88
|
+
await db.exec(
|
|
89
|
+
`CREATE TRIGGER site_search_document_au AFTER UPDATE ON site_search_document BEGIN
|
|
90
|
+
INSERT INTO site_search_document_fts (site_search_document_fts, rowid, ${columns})
|
|
91
|
+
VALUES ('delete', old.id, ${fromOld});
|
|
92
|
+
INSERT INTO site_search_document_fts (rowid, ${columns}) VALUES (new.id, ${fromNew});
|
|
93
|
+
END`,
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
await db.exec(
|
|
97
|
+
`INSERT INTO search_index_queue (node_id, generation, queued_at)
|
|
98
|
+
SELECT id, 1, ${db.dialect.now()} FROM node WHERE node_object_type = ?
|
|
99
|
+
ON CONFLICT (node_id) DO NOTHING`,
|
|
100
|
+
[ObjectTypes.Document],
|
|
101
|
+
)
|
|
102
|
+
},
|
|
103
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration 040 — two-factor sign-in (story 032).
|
|
3
|
+
*
|
|
4
|
+
* `two_factor_login` is a user's or member's authenticator, polymorphic on its
|
|
5
|
+
* owner's key as Umbraco's `umbracoTwoFactorLogin` is: the provider, the secret
|
|
6
|
+
* sealed with `CROMBOLO_SECRET_KEY`, and the last step a code was accepted for,
|
|
7
|
+
* so a code cannot be used twice. `two_factor_recovery_code` holds the hashes of
|
|
8
|
+
* the one-time recovery codes, each marked when used. docs/operations.md.
|
|
9
|
+
*/
|
|
10
|
+
import type { Db } from '../database.ts'
|
|
11
|
+
import type { Migration } from '../migrations.ts'
|
|
12
|
+
import { STATE_SITE_SEARCH } from './site-search.ts'
|
|
13
|
+
|
|
14
|
+
export const STATE_TWO_FACTOR = '{4c2e7a90-0040-4d1b-9f63-8a5e2c7b1d40}'
|
|
15
|
+
|
|
16
|
+
export const twoFactorMigration: Migration = {
|
|
17
|
+
from: STATE_SITE_SEARCH,
|
|
18
|
+
to: STATE_TWO_FACTOR,
|
|
19
|
+
name: 'AddTwoFactorLogin',
|
|
20
|
+
kind: 'expand',
|
|
21
|
+
release: '0.14.0',
|
|
22
|
+
async up(db: Db) {
|
|
23
|
+
const t = db.dialect.types
|
|
24
|
+
await db.exec(
|
|
25
|
+
`CREATE TABLE two_factor_login (
|
|
26
|
+
id ${t.identity},
|
|
27
|
+
owner_key ${t.varchar(36)} NOT NULL,
|
|
28
|
+
provider_name ${t.varchar(100)} NOT NULL,
|
|
29
|
+
secret ${t.text} NOT NULL,
|
|
30
|
+
last_used_step ${t.bigint},
|
|
31
|
+
create_date ${t.timestamp} NOT NULL,
|
|
32
|
+
UNIQUE (owner_key, provider_name)
|
|
33
|
+
)`,
|
|
34
|
+
)
|
|
35
|
+
await db.exec(
|
|
36
|
+
`CREATE TABLE two_factor_recovery_code (
|
|
37
|
+
id ${t.identity},
|
|
38
|
+
owner_key ${t.varchar(36)} NOT NULL,
|
|
39
|
+
code_hash ${t.varchar(64)} NOT NULL,
|
|
40
|
+
used_date ${t.timestamp}
|
|
41
|
+
)`,
|
|
42
|
+
)
|
|
43
|
+
await db.exec(
|
|
44
|
+
'CREATE INDEX ix_two_factor_recovery_code_owner ON two_factor_recovery_code (owner_key)',
|
|
45
|
+
)
|
|
46
|
+
},
|
|
47
|
+
}
|
package/src/search.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The backoffice search index: what a write queues, how the indexer turns a
|
|
3
|
-
* queued item into rows, and the two indexes the Examine dashboard shows.
|
|
4
|
-
* docs/search.md.
|
|
3
|
+
* queued item into rows, and the two indexes the Examine dashboard shows. The
|
|
4
|
+
* same indexer writes a page's site-search rows (`site-search.ts`). docs/search.md.
|
|
5
5
|
*/
|
|
6
6
|
import { ObjectTypes } from '@crombolo/core'
|
|
7
7
|
import type { Db } from './database.ts'
|
|
8
8
|
import { DbDate, fromDbBool } from './dialect.ts'
|
|
9
9
|
import { searchTerms, searchWords, type UnsearchableProperty, valueText } from './search-text.ts'
|
|
10
|
+
import { siteEntriesFor, writeSiteEntries } from './site-search.ts'
|
|
10
11
|
|
|
11
12
|
/** Umbraco's names for the two backoffice indexes, which the dashboard shows. */
|
|
12
13
|
export type SearchIndexName = 'InternalIndex' | 'MembersIndex'
|
|
@@ -156,6 +157,7 @@ export class SearchIndex {
|
|
|
156
157
|
)
|
|
157
158
|
if (claimed.length === 0) return false
|
|
158
159
|
await writeEntries(tx, nodeId, await entriesFor(tx, nodeId))
|
|
160
|
+
await writeSiteEntries(tx, nodeId, await siteEntriesFor(tx, nodeId))
|
|
159
161
|
await tx.exec('DELETE FROM search_index_queue WHERE node_id = ? AND generation = ?', [
|
|
160
162
|
nodeId,
|
|
161
163
|
generation,
|
|
@@ -272,11 +274,23 @@ const folded = (texts: readonly string[]) => texts.flatMap(searchWords).join(' '
|
|
|
272
274
|
* alias, for the blocks inside a value. A property a composition contributes
|
|
273
275
|
* counts on every type composing it, which is the key a block carries.
|
|
274
276
|
*/
|
|
275
|
-
|
|
277
|
+
function unsearchableElementProperties(db: Db): Promise<UnsearchableProperty> {
|
|
278
|
+
return hiddenElementProperties(db, 'p.searchable = ?', [db.dialect.boolValue(false)])
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The properties a condition on `property_type p` picks out, as a test by type
|
|
283
|
+
* key and alias: what a block's text is checked against. Shared with site search.
|
|
284
|
+
*/
|
|
285
|
+
export async function hiddenElementProperties(
|
|
286
|
+
db: Db,
|
|
287
|
+
condition: string,
|
|
288
|
+
params: readonly unknown[],
|
|
289
|
+
): Promise<UnsearchableProperty> {
|
|
276
290
|
const rows = await db.query<{ type_id: number; alias: string }>(
|
|
277
291
|
`SELECT p.content_type_id AS type_id, p.alias FROM property_type p
|
|
278
|
-
WHERE
|
|
279
|
-
[
|
|
292
|
+
WHERE (${condition}) AND p.retired_at IS NULL`,
|
|
293
|
+
[...params],
|
|
280
294
|
)
|
|
281
295
|
if (rows.length === 0) return () => false
|
|
282
296
|
const composedBy = new Map<number, number[]>()
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site search for visitors (story 140): what a page puts in the index, from
|
|
3
|
+
* its published values only, and the query a results page runs. The indexer
|
|
4
|
+
* that writes backoffice search rows writes these too (`search.ts`), from the
|
|
5
|
+
* same queue. docs/search.md.
|
|
6
|
+
*/
|
|
7
|
+
import { ObjectTypes, type SiteSearchLevel } from '@crombolo/core'
|
|
8
|
+
import type { Db } from './database.ts'
|
|
9
|
+
import { DbDate, fromDbBool } from './dialect.ts'
|
|
10
|
+
import { publishedValueRows } from './repositories/documents.ts'
|
|
11
|
+
import { hiddenElementProperties } from './search.ts'
|
|
12
|
+
import { searchTerms, searchWords, valueText } from './search-text.ts'
|
|
13
|
+
|
|
14
|
+
/** A page's text as written, by weight: what an excerpt is cut from. */
|
|
15
|
+
export interface SiteSearchText {
|
|
16
|
+
name: string
|
|
17
|
+
high: string[]
|
|
18
|
+
normal: string[]
|
|
19
|
+
low: string[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** One row of `site_search_document`: a page in one culture, '' when invariant. */
|
|
23
|
+
export interface SiteSearchEntry {
|
|
24
|
+
culture: string
|
|
25
|
+
text: SiteSearchText
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SiteSearchOptions {
|
|
29
|
+
/** The culture searched, lowercase; an invariant page matches every culture. */
|
|
30
|
+
culture: string
|
|
31
|
+
/** Content type aliases; every searchable type when empty or absent. */
|
|
32
|
+
types?: readonly string[]
|
|
33
|
+
skip: number
|
|
34
|
+
take: number
|
|
35
|
+
/**
|
|
36
|
+
* Protected nodes, by id, that the person searching may not open and may:
|
|
37
|
+
* `branchAccess` in `@crombolo/core`. A page is left out when the nearest
|
|
38
|
+
* protected node on its path is denied.
|
|
39
|
+
*/
|
|
40
|
+
access?: { denied: readonly number[]; allowed: readonly number[] }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SiteSearchHit {
|
|
44
|
+
key: string
|
|
45
|
+
culture: string
|
|
46
|
+
score: number
|
|
47
|
+
text: SiteSearchText
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const LEVELS: readonly SiteSearchLevel[] = ['high', 'normal', 'low']
|
|
51
|
+
|
|
52
|
+
const folded = (texts: readonly string[]) => texts.flatMap(searchWords).join(' ')
|
|
53
|
+
|
|
54
|
+
const marks = (n: number) => Array.from({ length: n }, () => '?').join(', ')
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What the site-search index holds for a page: a row per published culture,
|
|
58
|
+
* or one invariant row, from the values that culture shows visitors. Nothing
|
|
59
|
+
* for a page that is unpublished or trashed, whose type has no component (it
|
|
60
|
+
* has no address) or is left out of site search, or for anything not a page.
|
|
61
|
+
* A property marked `site-search = false` or `searchable = false` is left out,
|
|
62
|
+
* here and inside blocks.
|
|
63
|
+
*/
|
|
64
|
+
export async function siteEntriesFor(db: Db, nodeId: number): Promise<SiteSearchEntry[]> {
|
|
65
|
+
const [node] = await db.query<Record<string, unknown>>(
|
|
66
|
+
`SELECT n.node_object_type, n.trashed, c.content_type_id, ct.site_search, ct.is_element, d.published,
|
|
67
|
+
(SELECT COUNT(*) FROM content_type_template t WHERE t.content_type_node_id = ct.node_id) AS components
|
|
68
|
+
FROM node n JOIN content c ON c.node_id = n.id JOIN content_type ct ON ct.node_id = c.content_type_id
|
|
69
|
+
JOIN document d ON d.node_id = n.id
|
|
70
|
+
WHERE n.id = ?`,
|
|
71
|
+
[nodeId],
|
|
72
|
+
)
|
|
73
|
+
if (
|
|
74
|
+
!node ||
|
|
75
|
+
String(node.node_object_type) !== ObjectTypes.Document ||
|
|
76
|
+
fromDbBool(node.trashed) ||
|
|
77
|
+
!fromDbBool(node.published) ||
|
|
78
|
+
!fromDbBool(node.site_search) ||
|
|
79
|
+
fromDbBool(node.is_element) ||
|
|
80
|
+
Number(node.components) === 0
|
|
81
|
+
)
|
|
82
|
+
return []
|
|
83
|
+
|
|
84
|
+
const [invariant] = await db.query<{ id: number; text: string | null }>(
|
|
85
|
+
`SELECT cv.id, cv.text FROM content_version cv JOIN document_version dv ON dv.id = cv.id
|
|
86
|
+
WHERE cv.node_id = ? AND dv.published = ? ORDER BY cv.id DESC LIMIT 1`,
|
|
87
|
+
[nodeId, db.dialect.boolValue(true)],
|
|
88
|
+
)
|
|
89
|
+
if (!invariant) return []
|
|
90
|
+
const cultures = (
|
|
91
|
+
await db.query<Record<string, unknown>>(
|
|
92
|
+
`SELECT dcv.language_id, l.iso_code, dcv.published_event_id, dcv.published_name, dcv.name
|
|
93
|
+
FROM document_culture_variation dcv JOIN language l ON l.id = dcv.language_id
|
|
94
|
+
WHERE dcv.node_id = ? AND dcv.published = ?`,
|
|
95
|
+
[nodeId, db.dialect.boolValue(true)],
|
|
96
|
+
)
|
|
97
|
+
).map((row) => ({
|
|
98
|
+
languageId: Number(row.language_id),
|
|
99
|
+
culture: String(row.iso_code).toLowerCase(),
|
|
100
|
+
eventId: Number(row.published_event_id ?? 0),
|
|
101
|
+
name: String(row.published_name ?? row.name ?? ''),
|
|
102
|
+
}))
|
|
103
|
+
|
|
104
|
+
const latest = Math.max(Number(invariant.id), ...cultures.map((c) => c.eventId))
|
|
105
|
+
const history = (
|
|
106
|
+
await db.query<Record<string, unknown>>(
|
|
107
|
+
`SELECT pv.id, pv.event_id, pv.property_type_id, pv.language_id, pv.segment,
|
|
108
|
+
pv.varchar_value, pv.text_value, p.alias, p.site_search
|
|
109
|
+
FROM property_value pv JOIN property_type p ON p.id = pv.property_type_id
|
|
110
|
+
WHERE pv.node_id = ? AND pv.event_id <= ? AND p.retired_at IS NULL
|
|
111
|
+
AND p.searchable = ? AND p.site_search <> 'off'
|
|
112
|
+
ORDER BY pv.id`,
|
|
113
|
+
[nodeId, latest, db.dialect.boolValue(true)],
|
|
114
|
+
)
|
|
115
|
+
).map((row) => ({
|
|
116
|
+
eventId: Number(row.event_id),
|
|
117
|
+
propertyTypeId: Number(row.property_type_id),
|
|
118
|
+
languageId: row.language_id === null ? null : Number(row.language_id),
|
|
119
|
+
segment: (row.segment as string | null) ?? null,
|
|
120
|
+
raw: row.text_value ?? row.varchar_value,
|
|
121
|
+
level: (LEVELS as readonly string[]).includes(String(row.site_search))
|
|
122
|
+
? (String(row.site_search) as SiteSearchLevel)
|
|
123
|
+
: 'normal',
|
|
124
|
+
}))
|
|
125
|
+
const shown = publishedValueRows(history, Number(invariant.id), cultures).filter(
|
|
126
|
+
(row) => row.segment === null,
|
|
127
|
+
)
|
|
128
|
+
const hidden = await hiddenElementProperties(db, "p.searchable = ? OR p.site_search = 'off'", [
|
|
129
|
+
db.dialect.boolValue(false),
|
|
130
|
+
])
|
|
131
|
+
|
|
132
|
+
const entry = (culture: string, languageId: number | null, name: string): SiteSearchEntry => {
|
|
133
|
+
const text: SiteSearchText = { name, high: [], normal: [], low: [] }
|
|
134
|
+
for (const row of shown) {
|
|
135
|
+
if (row.languageId !== null && row.languageId !== languageId) continue
|
|
136
|
+
text[row.level].push(...valueText(row.raw, hidden))
|
|
137
|
+
}
|
|
138
|
+
return { culture, text }
|
|
139
|
+
}
|
|
140
|
+
if (cultures.length === 0) return [entry('', null, String(invariant.text ?? ''))]
|
|
141
|
+
return cultures.map((c) => entry(c.culture, c.languageId, c.name))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Replaces a page's site-search rows. */
|
|
145
|
+
export async function writeSiteEntries(
|
|
146
|
+
tx: Db,
|
|
147
|
+
nodeId: number,
|
|
148
|
+
entries: readonly SiteSearchEntry[],
|
|
149
|
+
): Promise<void> {
|
|
150
|
+
await tx.exec('DELETE FROM site_search_document WHERE node_id = ?', [nodeId])
|
|
151
|
+
const now = DbDate.toDb(new Date())
|
|
152
|
+
for (const entry of entries)
|
|
153
|
+
await tx.exec(
|
|
154
|
+
`INSERT INTO site_search_document (node_id, culture, name, high, normal, low, text, indexed_at)
|
|
155
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
156
|
+
[
|
|
157
|
+
nodeId,
|
|
158
|
+
entry.culture,
|
|
159
|
+
folded([entry.text.name]),
|
|
160
|
+
folded(entry.text.high),
|
|
161
|
+
folded(entry.text.normal),
|
|
162
|
+
folded(entry.text.low),
|
|
163
|
+
JSON.stringify(entry.text),
|
|
164
|
+
now,
|
|
165
|
+
],
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The visitor search over published pages. Every word of the query must be in
|
|
171
|
+
* a page as a word prefix; the name counts most, then the high, normal and low
|
|
172
|
+
* properties. Pages the person searching may not open are left out in the query
|
|
173
|
+
* itself, so `total` counts only what they may see.
|
|
174
|
+
*/
|
|
175
|
+
export class SiteSearch {
|
|
176
|
+
#db: Db
|
|
177
|
+
|
|
178
|
+
constructor(db: Db) {
|
|
179
|
+
this.#db = db
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async query(
|
|
183
|
+
term: string,
|
|
184
|
+
options: SiteSearchOptions,
|
|
185
|
+
): Promise<{ total: number; items: SiteSearchHit[] }> {
|
|
186
|
+
const terms = searchTerms(term)
|
|
187
|
+
if (terms.length === 0) return { total: 0, items: [] }
|
|
188
|
+
const where = ['s.culture IN (?, ?)', 'n.trashed = ?']
|
|
189
|
+
const params: unknown[] = [
|
|
190
|
+
this.#db.dialect.fullTextQuery(terms),
|
|
191
|
+
options.culture.toLowerCase(),
|
|
192
|
+
'',
|
|
193
|
+
this.#db.dialect.boolValue(false),
|
|
194
|
+
]
|
|
195
|
+
const types = options.types?.filter(Boolean) ?? []
|
|
196
|
+
if (types.length > 0) {
|
|
197
|
+
where.push(`ct.alias IN (${marks(types.length)})`)
|
|
198
|
+
params.push(...types)
|
|
199
|
+
}
|
|
200
|
+
const denied = options.access?.denied ?? []
|
|
201
|
+
const allowed = options.access?.allowed ?? []
|
|
202
|
+
if (denied.length > 0) {
|
|
203
|
+
// Closed: under a denied node, with no allowed node between it and the page.
|
|
204
|
+
const opened =
|
|
205
|
+
allowed.length === 0
|
|
206
|
+
? ''
|
|
207
|
+
: `AND NOT EXISTS (SELECT 1 FROM node q WHERE q.id IN (${marks(allowed.length)})
|
|
208
|
+
AND (q.id = n.id OR n.path LIKE q.path || ',%') AND q.path LIKE p.path || ',%')`
|
|
209
|
+
where.push(
|
|
210
|
+
`NOT EXISTS (SELECT 1 FROM node p WHERE p.id IN (${marks(denied.length)})
|
|
211
|
+
AND (p.id = n.id OR n.path LIKE p.path || ',%') ${opened})`,
|
|
212
|
+
)
|
|
213
|
+
params.push(...denied, ...allowed)
|
|
214
|
+
}
|
|
215
|
+
const from = `FROM (${this.#db.dialect.siteSearchHits}) h
|
|
216
|
+
JOIN site_search_document s ON s.id = h.row_id
|
|
217
|
+
JOIN node n ON n.id = s.node_id
|
|
218
|
+
JOIN content c ON c.node_id = n.id
|
|
219
|
+
JOIN content_type ct ON ct.node_id = c.content_type_id
|
|
220
|
+
WHERE ${where.join(' AND ')}`
|
|
221
|
+
const [count] = await this.#db.query<{ n: number }>(`SELECT COUNT(*) AS n ${from}`, params)
|
|
222
|
+
const rows = await this.#db.query<Record<string, unknown>>(
|
|
223
|
+
`SELECT n.unique_id AS key, s.culture, s.text, h.score ${from}
|
|
224
|
+
ORDER BY h.score DESC, n.id LIMIT ? OFFSET ?`,
|
|
225
|
+
[...params, options.take, options.skip],
|
|
226
|
+
)
|
|
227
|
+
return {
|
|
228
|
+
total: Number(count?.n ?? 0),
|
|
229
|
+
items: rows.map((row) => ({
|
|
230
|
+
key: String(row.key).toLowerCase(),
|
|
231
|
+
culture: String(row.culture),
|
|
232
|
+
score: Number(row.score),
|
|
233
|
+
text: JSON.parse(String(row.text)) as SiteSearchText,
|
|
234
|
+
})),
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|