@crombolo/server 0.9.1 → 0.11.0
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 +11 -11
- package/src/adapters/audit.ts +186 -0
- package/src/adapters/bundles.ts +84 -9
- package/src/adapters/schema-files.ts +1 -1
- package/src/adapters/search-index.ts +31 -0
- package/src/backup.ts +1 -1
- package/src/bundle-install.ts +417 -0
- package/src/bundle-sections.ts +424 -0
- package/src/bundle-tables.ts +161 -0
- package/src/bundles.ts +139 -38
- package/src/config.ts +24 -3
- package/src/database.ts +50 -11
- package/src/dictionary-transfer.ts +1 -1
- package/src/email.ts +1 -1
- package/src/extensions-notice.ts +2 -2
- package/src/form-files.ts +2 -2
- package/src/form-submit.ts +1 -1
- package/src/form-workflows.ts +2 -2
- package/src/forms.ts +1 -1
- package/src/index.ts +4 -0
- package/src/jobs.ts +74 -10
- package/src/marketplace.ts +117 -32
- package/src/no-nodes.ts +1 -1
- package/src/ports.ts +7 -1
- package/src/schema.ts +1 -1
- package/src/server.ts +146 -12
- package/src/status.ts +29 -0
- package/src/tar.ts +85 -0
- package/src/zip.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "The crombolo HTTP server: routing, configuration, media stores and background jobs.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Crombolo",
|
|
@@ -28,16 +28,16 @@
|
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@crombolo/api-management": "0.
|
|
32
|
-
"@crombolo/assistant": "0.
|
|
33
|
-
"@crombolo/auth": "0.
|
|
34
|
-
"@crombolo/backoffice-host": "0.
|
|
35
|
-
"@crombolo/contracts": "0.
|
|
36
|
-
"@crombolo/core": "0.
|
|
37
|
-
"@crombolo/data": "0.
|
|
38
|
-
"@crombolo/render": "0.
|
|
39
|
-
"@crombolo/schema": "0.
|
|
40
|
-
"@crombolo/transfer": "0.
|
|
31
|
+
"@crombolo/api-management": "0.11.0",
|
|
32
|
+
"@crombolo/assistant": "0.11.0",
|
|
33
|
+
"@crombolo/auth": "0.11.0",
|
|
34
|
+
"@crombolo/backoffice-host": "0.11.0",
|
|
35
|
+
"@crombolo/contracts": "0.11.0",
|
|
36
|
+
"@crombolo/core": "0.11.0",
|
|
37
|
+
"@crombolo/data": "0.11.0",
|
|
38
|
+
"@crombolo/render": "0.11.0",
|
|
39
|
+
"@crombolo/schema": "0.11.0",
|
|
40
|
+
"@crombolo/transfer": "0.11.0",
|
|
41
41
|
"@logtape/logtape": "^2.3.8"
|
|
42
42
|
}
|
|
43
43
|
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The audit log's ports: where the router writes, what an item's Info tab reads,
|
|
3
|
+
* and the Settings dashboard's endpoint. docs/audit-log.md.
|
|
4
|
+
*/
|
|
5
|
+
import type { AuditHistoryPort, AuditSink } from '@crombolo/api-management'
|
|
6
|
+
import {
|
|
7
|
+
AUDIT_CATEGORIES,
|
|
8
|
+
type AuditCategory,
|
|
9
|
+
type AuditEntry,
|
|
10
|
+
type AuditEntryInput,
|
|
11
|
+
AuditLog,
|
|
12
|
+
type Db,
|
|
13
|
+
} from '@crombolo/data'
|
|
14
|
+
import { logger } from '../logging.ts'
|
|
15
|
+
|
|
16
|
+
const log = logger('Audit')
|
|
17
|
+
|
|
18
|
+
/** The oldest moment the log still keeps, or undefined when it keeps everything. */
|
|
19
|
+
export function auditRetainedSince(retentionDays: number, now = new Date()): Date | undefined {
|
|
20
|
+
return retentionDays > 0 ? new Date(now.getTime() - retentionDays * 86_400_000) : undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Writes an entry without ever failing what it records: the change has already
|
|
25
|
+
* been made by the time it is written, so a failure here is logged, not raised.
|
|
26
|
+
*/
|
|
27
|
+
export async function recordAudit(
|
|
28
|
+
db: Db,
|
|
29
|
+
entries: AuditEntryInput | readonly AuditEntryInput[],
|
|
30
|
+
): Promise<void> {
|
|
31
|
+
try {
|
|
32
|
+
await new AuditLog(db).record(entries)
|
|
33
|
+
} catch (error) {
|
|
34
|
+
log.error('Could not write to the audit log: {error}', { error })
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createAuditSink(db: Db): AuditSink {
|
|
39
|
+
const audit = new AuditLog(db)
|
|
40
|
+
return {
|
|
41
|
+
async describe(entityType, key) {
|
|
42
|
+
try {
|
|
43
|
+
return await audit.describe(entityType, key)
|
|
44
|
+
} catch (error) {
|
|
45
|
+
log.warning('Could not name {entityType} {key} for the audit log: {error}', {
|
|
46
|
+
entityType,
|
|
47
|
+
key,
|
|
48
|
+
error,
|
|
49
|
+
})
|
|
50
|
+
return { entityType, entityKey: key, entityName: null }
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
record: (entries) =>
|
|
54
|
+
recordAudit(
|
|
55
|
+
db,
|
|
56
|
+
entries.map((entry) => ({ ...entry, source: 'request' as const })),
|
|
57
|
+
),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createAuditHistory(db: Db, retentionDays: number): AuditHistoryPort {
|
|
62
|
+
const audit = new AuditLog(db)
|
|
63
|
+
let started: Date | undefined
|
|
64
|
+
return {
|
|
65
|
+
async forItem(key) {
|
|
66
|
+
const { items } = await audit.query({ entityKey: key }, { skip: 0, take: 10_000 })
|
|
67
|
+
return items
|
|
68
|
+
},
|
|
69
|
+
async startedAt() {
|
|
70
|
+
started ??= await audit.startedAt()
|
|
71
|
+
return started
|
|
72
|
+
},
|
|
73
|
+
retainedSince: () => auditRetainedSince(retentionDays),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function toResponse(entry: AuditEntry) {
|
|
78
|
+
return {
|
|
79
|
+
id: entry.id,
|
|
80
|
+
occurredAt: entry.occurredAt.toISOString(),
|
|
81
|
+
category: entry.category,
|
|
82
|
+
action: entry.action,
|
|
83
|
+
source: entry.source,
|
|
84
|
+
user: entry.userKey || entry.userName ? { key: entry.userKey, name: entry.userName } : null,
|
|
85
|
+
item: entry.entityType
|
|
86
|
+
? { type: entry.entityType, key: entry.entityKey, name: entry.entityName }
|
|
87
|
+
: null,
|
|
88
|
+
ipAddress: entry.ipAddress,
|
|
89
|
+
forwardedFor: entry.forwardedFor,
|
|
90
|
+
detail: entry.detail,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function dateParam(value: string | null): Date | undefined | null {
|
|
95
|
+
if (!value) return undefined
|
|
96
|
+
const parsed = new Date(value)
|
|
97
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const badRequest = (detail: string) =>
|
|
101
|
+
Response.json({ title: 'Invalid request', status: 400, detail }, { status: 400 })
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The Settings dashboard's reads: `GET /` lists entries, newest first, filtered
|
|
105
|
+
* by user, item, category, action, date and text; `GET /users` names everyone in
|
|
106
|
+
* the log. Nothing here writes, so an entry cannot be changed through it.
|
|
107
|
+
*/
|
|
108
|
+
export function createAuditHost(db: Db, retentionDays: number) {
|
|
109
|
+
const audit = new AuditLog(db)
|
|
110
|
+
return {
|
|
111
|
+
async handle(request: Request, pathname: string): Promise<Response | undefined> {
|
|
112
|
+
if (pathname !== '' && pathname !== '/' && pathname !== '/users') return undefined
|
|
113
|
+
if (request.method !== 'GET')
|
|
114
|
+
return new Response('Method Not Allowed', { status: 405, headers: { allow: 'GET' } })
|
|
115
|
+
if (pathname === '/users') return Response.json({ items: await audit.users() })
|
|
116
|
+
|
|
117
|
+
const search = new URL(request.url).searchParams
|
|
118
|
+
const skip = Number(search.get('skip') ?? 0)
|
|
119
|
+
const take = Number(search.get('take') ?? 50)
|
|
120
|
+
if (!Number.isInteger(skip) || skip < 0 || !Number.isInteger(take) || take < 1 || take > 500)
|
|
121
|
+
return badRequest('skip must be 0 or more, and take between 1 and 500.')
|
|
122
|
+
const category = search.get('category') ?? undefined
|
|
123
|
+
if (category && !AUDIT_CATEGORIES.includes(category as AuditCategory))
|
|
124
|
+
return badRequest(`There is no category "${category}".`)
|
|
125
|
+
const since = dateParam(search.get('since'))
|
|
126
|
+
const until = dateParam(search.get('until'))
|
|
127
|
+
if (since === null || until === null) return badRequest('since and until must be dates.')
|
|
128
|
+
const result = await audit.query(
|
|
129
|
+
{
|
|
130
|
+
userKey: search.get('user') ?? undefined,
|
|
131
|
+
entityKey: search.get('item') ?? undefined,
|
|
132
|
+
category: category as AuditCategory | undefined,
|
|
133
|
+
action: search.get('action') ?? undefined,
|
|
134
|
+
since,
|
|
135
|
+
until,
|
|
136
|
+
text: search.get('text') ?? undefined,
|
|
137
|
+
},
|
|
138
|
+
{ skip, take },
|
|
139
|
+
)
|
|
140
|
+
return Response.json({
|
|
141
|
+
total: result.total,
|
|
142
|
+
retentionDays,
|
|
143
|
+
items: result.items.map(toResponse),
|
|
144
|
+
})
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const VERB: Record<string, string> = {
|
|
150
|
+
POST: 'Create',
|
|
151
|
+
PUT: 'Save',
|
|
152
|
+
PATCH: 'Save',
|
|
153
|
+
DELETE: 'Delete',
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Crombolo's own endpoints that change something, by their first segment. */
|
|
157
|
+
const PLUGIN_AREAS: Record<string, { category: AuditCategory; entityType: string }> = {
|
|
158
|
+
'schema-import': { category: 'schema', entityType: 'Schema' },
|
|
159
|
+
git: { category: 'schema', entityType: 'Schema' },
|
|
160
|
+
seo: { category: 'settings', entityType: 'SeoSettings' },
|
|
161
|
+
bundles: { category: 'system', entityType: 'Bundle' },
|
|
162
|
+
forms: { category: 'content', entityType: 'Form' },
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const PLUGIN_ACTIONS: Record<string, string> = {
|
|
166
|
+
'schema-import': 'Import',
|
|
167
|
+
'git/commit': 'Commit',
|
|
168
|
+
'bundles/install': 'Install',
|
|
169
|
+
'bundles/uninstall': 'Uninstall',
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The entry for a write to `<plugin>/api/<subpath>`: the area it is in, and what it did there. */
|
|
173
|
+
export function pluginAuditPlan(
|
|
174
|
+
method: string,
|
|
175
|
+
subpath: string,
|
|
176
|
+
): Pick<AuditEntryInput, 'category' | 'action' | 'entityType' | 'entityKey' | 'detail'> {
|
|
177
|
+
const [area = '', ...rest] = subpath.split('/')
|
|
178
|
+
const known = PLUGIN_AREAS[area]
|
|
179
|
+
return {
|
|
180
|
+
category: known?.category ?? 'settings',
|
|
181
|
+
entityType: known?.entityType ?? area,
|
|
182
|
+
action: PLUGIN_ACTIONS[subpath] ?? VERB[method] ?? method,
|
|
183
|
+
entityKey: rest.length > 0 ? rest.join('/') : null,
|
|
184
|
+
detail: `${method} ${subpath}`,
|
|
185
|
+
}
|
|
186
|
+
}
|
package/src/adapters/bundles.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Bundles: the definitions, and the
|
|
3
|
-
* `docs/
|
|
2
|
+
* Bundles: the definitions, and the npm package a download builds from one.
|
|
3
|
+
* `docs/bundles.md`.
|
|
4
4
|
*
|
|
5
5
|
* Nothing new is serialized here. Schema travels as the canonical TOML
|
|
6
6
|
* `@crombolo/schema` already writes, content and media as the bundle
|
|
@@ -48,14 +48,27 @@ import {
|
|
|
48
48
|
type BundleKind,
|
|
49
49
|
type BundleNode,
|
|
50
50
|
type BundleSection,
|
|
51
|
+
bundleIdFor,
|
|
51
52
|
type ContentSet,
|
|
52
53
|
exportBundle,
|
|
54
|
+
isPackageName,
|
|
55
|
+
packageNameFor,
|
|
53
56
|
writeBundle,
|
|
57
|
+
writeBundlePackage,
|
|
54
58
|
} from '@crombolo/transfer'
|
|
59
|
+
import { VERSION } from '../config.ts'
|
|
55
60
|
import { dictionaryToUdt, writeDictionaryUdt } from '../dictionary-transfer.ts'
|
|
56
61
|
import { readBlobs } from '../media-files.ts'
|
|
57
62
|
import type { MediaStore } from '../media-store.ts'
|
|
58
|
-
import {
|
|
63
|
+
import { writeNpmTarball } from '../tar.ts'
|
|
64
|
+
|
|
65
|
+
/** The npm package a definition is built as: what was set on it, or what its name gives. */
|
|
66
|
+
export function bundlePackageOf(row: Bundle): { name: string; version: string } {
|
|
67
|
+
return {
|
|
68
|
+
name: row.packageName ?? packageNameFor(row.name),
|
|
69
|
+
version: row.packageVersion ?? '1.0.0',
|
|
70
|
+
}
|
|
71
|
+
}
|
|
59
72
|
|
|
60
73
|
/** The content kinds a bundle's picks can name. */
|
|
61
74
|
const CONTENT_OBJECT_TYPES = [ObjectTypes.Document, ObjectTypes.Media, ObjectTypes.Element]
|
|
@@ -376,17 +389,19 @@ export function createPackagePort(db: Db, options: PackagePortOptions): PackageP
|
|
|
376
389
|
},
|
|
377
390
|
|
|
378
391
|
/**
|
|
379
|
-
* The definition as one bundle,
|
|
392
|
+
* The definition as one bundle, packed as the npm package it is.
|
|
380
393
|
*
|
|
381
394
|
* Everything goes through `writeBundle` so there is a single manifest over
|
|
382
|
-
* the whole
|
|
383
|
-
* them, and names the
|
|
384
|
-
*
|
|
395
|
+
* the whole payload: it declares the sections, carries the hash that covers
|
|
396
|
+
* them, and names the bundle. The package around it is what gives it a
|
|
397
|
+
* name, a version and the core it was written for, so the download is
|
|
398
|
+
* something `bun add ./file.tgz` and `bun publish` take as it is.
|
|
385
399
|
*/
|
|
386
400
|
async build(id): Promise<BuiltPackage | undefined> {
|
|
387
401
|
const row = await bundles.byId(id)
|
|
388
402
|
if (!row) return undefined
|
|
389
403
|
const from = definition(row)
|
|
404
|
+
const pkg = bundlePackageOf(row)
|
|
390
405
|
|
|
391
406
|
// The schema version the site is actually at, so the files a package
|
|
392
407
|
// carries say where they came from rather than claiming version 0.
|
|
@@ -422,11 +437,71 @@ export function createPackagePort(db: Db, options: PackagePortOptions): PackageP
|
|
|
422
437
|
|
|
423
438
|
const labelled: ContentSet = { ...set, manifest: { ...set.manifest, label: from.name } }
|
|
424
439
|
const encoder = new TextEncoder()
|
|
425
|
-
const
|
|
440
|
+
const files = writeBundlePackage(writeBundle(labelled, content.blobs, sections), {
|
|
441
|
+
name: pkg.name,
|
|
442
|
+
version: pkg.version,
|
|
443
|
+
id: bundleIdFor(pkg.name.replace(/^@[^/]+\//, '')),
|
|
444
|
+
label: from.name,
|
|
445
|
+
peerRange: `^${VERSION}`,
|
|
446
|
+
})
|
|
447
|
+
const entries = files.map((file) => ({
|
|
426
448
|
path: file.path,
|
|
427
449
|
bytes: file.bytes ?? encoder.encode(file.text ?? ''),
|
|
428
450
|
}))
|
|
429
|
-
return {
|
|
451
|
+
return {
|
|
452
|
+
fileName: packageFileName(pkg.name, pkg.version),
|
|
453
|
+
contentType: 'application/gzip',
|
|
454
|
+
bytes: writeNpmTarball(entries),
|
|
455
|
+
}
|
|
430
456
|
},
|
|
431
457
|
}
|
|
432
458
|
}
|
|
459
|
+
|
|
460
|
+
/** A created bundle's npm package as the Created view edits it. */
|
|
461
|
+
export interface CreatedBundlePackage {
|
|
462
|
+
name: string
|
|
463
|
+
version: string
|
|
464
|
+
/** Whether each was set, rather than derived from the definition's name. */
|
|
465
|
+
set: { name: boolean; version: boolean }
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
|
|
469
|
+
|
|
470
|
+
/** The npm package a definition builds as, or undefined when it is gone. */
|
|
471
|
+
export async function readCreatedBundlePackage(
|
|
472
|
+
db: Db,
|
|
473
|
+
id: string,
|
|
474
|
+
): Promise<CreatedBundlePackage | undefined> {
|
|
475
|
+
const row = await new BundleRepository(db).byId(id)
|
|
476
|
+
if (!row) return undefined
|
|
477
|
+
return {
|
|
478
|
+
...bundlePackageOf(row),
|
|
479
|
+
set: { name: row.packageName !== null, version: row.packageVersion !== null },
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Sets the npm package a definition builds as. An empty value goes back to its
|
|
485
|
+
* default; anything else must be what npm itself would accept.
|
|
486
|
+
*/
|
|
487
|
+
export async function writeCreatedBundlePackage(
|
|
488
|
+
db: Db,
|
|
489
|
+
id: string,
|
|
490
|
+
input: { name?: unknown; version?: unknown },
|
|
491
|
+
): Promise<{ ok: true; package: CreatedBundlePackage } | { ok: false; message: string }> {
|
|
492
|
+
const bundles = new BundleRepository(db)
|
|
493
|
+
if (!(await bundles.byId(id))) return { ok: false, message: 'That bundle no longer exists.' }
|
|
494
|
+
const text = (value: unknown) => (typeof value === 'string' ? value.trim() : '')
|
|
495
|
+
const name = text(input.name)
|
|
496
|
+
const version = text(input.version)
|
|
497
|
+
if (name && !isPackageName(name))
|
|
498
|
+
return {
|
|
499
|
+
ok: false,
|
|
500
|
+
message:
|
|
501
|
+
'A package name is lowercase letters, digits, dots, hyphens and underscores, optionally with an @scope/ in front.',
|
|
502
|
+
}
|
|
503
|
+
if (version && !SEMVER.test(version))
|
|
504
|
+
return { ok: false, message: 'A version is three numbers, like 1.0.0.' }
|
|
505
|
+
await bundles.setPackage(id, { name: name || null, version: version || null })
|
|
506
|
+
return { ok: true, package: (await readCreatedBundlePackage(db, id)) as CreatedBundlePackage }
|
|
507
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The backoffice writes schema files. A document-type save lands in the
|
|
3
3
|
* database as before and, when the directory is writable, in
|
|
4
4
|
* `schema/content-types/<alias>.toml` — the file the next boot will sync.
|
|
5
|
-
* docs/
|
|
5
|
+
* docs/schema-as-code.md, "The backoffice writes files".
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** The Examine dashboard's port over the search index. docs/search.md. */
|
|
2
|
+
import type { SearchIndexInfo, SearchIndexPort } from '@crombolo/api-management'
|
|
3
|
+
import { type Db, SEARCH_INDEXES, SearchIndex, type SearchIndexDefinition } from '@crombolo/data'
|
|
4
|
+
|
|
5
|
+
export function createSearchIndexPort(db: Db, onQueued?: () => void): SearchIndexPort {
|
|
6
|
+
const search = new SearchIndex(db)
|
|
7
|
+
const info = async (index: SearchIndexDefinition): Promise<SearchIndexInfo> => ({
|
|
8
|
+
name: index.name,
|
|
9
|
+
searcherName: index.searcherName,
|
|
10
|
+
...(await search.stats(index)),
|
|
11
|
+
})
|
|
12
|
+
return {
|
|
13
|
+
indexes: () => Promise.all(SEARCH_INDEXES.map(info)),
|
|
14
|
+
async index(name) {
|
|
15
|
+
const index = SearchIndex.definition(name)
|
|
16
|
+
return index ? info(index) : undefined
|
|
17
|
+
},
|
|
18
|
+
async rebuild(name) {
|
|
19
|
+
const index = SearchIndex.definition(name)
|
|
20
|
+
if (!index) return false
|
|
21
|
+
await search.rebuild(index)
|
|
22
|
+
onQueued?.()
|
|
23
|
+
return true
|
|
24
|
+
},
|
|
25
|
+
searchers: () => SEARCH_INDEXES.map((index) => index.searcherName),
|
|
26
|
+
async query(searcherName, term, paging) {
|
|
27
|
+
const index = SearchIndex.bySearcher(searcherName)
|
|
28
|
+
return index ? search.query(index, term, paging) : undefined
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/backup.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Rollback is a backup (docs/
|
|
2
|
+
* Rollback is a backup (docs/packaging-and-upgrades.md): SQLite is copied
|
|
3
3
|
* beside the database; Postgres runs the operator's `CROMBOLO_PG_DUMP` command
|
|
4
4
|
* or refuses unless the caller vouches with `--backup-taken`.
|
|
5
5
|
*/
|