@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/data",
3
- "version": "0.13.2",
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.13.2"
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
- /** The live properties of a type, by alias, variation and storage: what its items are indexed by. */
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
- return rows
43
- .map(
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
- .join('|')
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
  }
@@ -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
- async function unsearchableElementProperties(db: Db): Promise<UnsearchableProperty> {
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 p.searchable = ? AND p.retired_at IS NULL`,
279
- [db.dialect.boolValue(false)],
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
+ }