@wbce-d9/api 12.5.3 → 12.5.5
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/dist/app.js
CHANGED
|
@@ -186,7 +186,9 @@ export default async function createApp() {
|
|
|
186
186
|
await emitter.emitInit('middlewares.after', { app });
|
|
187
187
|
await emitter.emitInit('routes.before', { app });
|
|
188
188
|
app.use('/auth', authRouter);
|
|
189
|
-
|
|
189
|
+
if (env['SERVE_GRAPHQL']) {
|
|
190
|
+
app.use('/graphql', graphqlRouter);
|
|
191
|
+
}
|
|
190
192
|
app.use('/activity', activityRouter);
|
|
191
193
|
app.use('/assets', assetsRouter);
|
|
192
194
|
app.use('/collections', collectionsRouter);
|
|
@@ -1,9 +1,47 @@
|
|
|
1
1
|
import { Router } from 'express';
|
|
2
|
-
import type { User } from '../../types/index.js';
|
|
2
|
+
import type { PrimaryKey, User } from '../../types/index.js';
|
|
3
3
|
import { AuthDriver } from '../auth.js';
|
|
4
4
|
export declare class LocalAuthDriver extends AuthDriver {
|
|
5
5
|
getUserID(payload: Record<string, any>): Promise<string>;
|
|
6
6
|
verify(user: User, password?: string): Promise<void>;
|
|
7
7
|
login(user: User, payload: Record<string, any>): Promise<void>;
|
|
8
|
+
private getRegistrationService;
|
|
9
|
+
private getAccountByEmail;
|
|
10
|
+
/**
|
|
11
|
+
* Build the confirmation link handed to a freshly registered user.
|
|
12
|
+
*
|
|
13
|
+
* Without a `verification_url` the link points back at this provider's own verify route, so the
|
|
14
|
+
* endpoint works with no frontend at all. With one, the token is appended to the caller's page,
|
|
15
|
+
* which is expected to forward it here.
|
|
16
|
+
*/
|
|
17
|
+
private registerUrl;
|
|
18
|
+
/**
|
|
19
|
+
* When was the last confirmation mail for this pending account sent, if ever.
|
|
20
|
+
*/
|
|
21
|
+
private lastMailSentAt;
|
|
22
|
+
/**
|
|
23
|
+
* Public self-service registration for this provider.
|
|
24
|
+
*
|
|
25
|
+
* The account is created `draft`, and d9 won't issue a token for a draft user, so nothing is
|
|
26
|
+
* reachable until the emailed link is redeemed by `verifyEmail`. An address that already has a
|
|
27
|
+
* non-draft account is ignored silently — the caller can't tell the two cases apart. An account
|
|
28
|
+
* still awaiting confirmation has its mail re-sent, which is how a client implements "resend"
|
|
29
|
+
* without a second route.
|
|
30
|
+
*/
|
|
31
|
+
registerUser(provider: string, input: {
|
|
32
|
+
email: string;
|
|
33
|
+
password: string;
|
|
34
|
+
first_name?: string | null;
|
|
35
|
+
last_name?: string | null;
|
|
36
|
+
verification_url?: string | null;
|
|
37
|
+
}): Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* Redeem a registration token and activate the account. Returns the user id so the route can
|
|
40
|
+
* redirect to it.
|
|
41
|
+
*
|
|
42
|
+
* A token whose account is no longer `draft` — a link clicked twice, or an account since
|
|
43
|
+
* suspended — is reported as invalid rather than replayed.
|
|
44
|
+
*/
|
|
45
|
+
verifyEmail(provider: string, token: string): Promise<PrimaryKey>;
|
|
8
46
|
}
|
|
9
47
|
export declare function createLocalAuthRouter(provider: string): Router;
|
|
@@ -1,16 +1,33 @@
|
|
|
1
|
+
import { parseJSON } from '@wbce-d9/utils';
|
|
1
2
|
import argon2 from 'argon2';
|
|
2
3
|
import { Router } from 'express';
|
|
3
4
|
import Joi from 'joi';
|
|
5
|
+
import jwt from 'jsonwebtoken';
|
|
4
6
|
import { performance } from 'perf_hooks';
|
|
5
7
|
import { ACCESS_COOKIE_OPTIONS, REFRESH_COOKIE_OPTIONS } from '../../constants.js';
|
|
8
|
+
import getDatabase from '../../database/index.js';
|
|
9
|
+
import emitter from '../../emitter.js';
|
|
6
10
|
import env from '../../env.js';
|
|
7
|
-
import { InvalidCredentialsException, InvalidPayloadException } from '../../exceptions/index.js';
|
|
11
|
+
import { ForbiddenException, InvalidCredentialsException, InvalidPayloadException, InvalidTokenException, } from '../../exceptions/index.js';
|
|
12
|
+
import logger from '../../logger.js';
|
|
8
13
|
import { respond } from '../../middleware/respond.js';
|
|
9
14
|
import { AuthenticationService } from '../../services/authentication.js';
|
|
15
|
+
import { MailService } from '../../services/mail/index.js';
|
|
16
|
+
import { UsersService } from '../../services/users.js';
|
|
10
17
|
import asyncHandler from '../../utils/async-handler.js';
|
|
18
|
+
import { getConfigFromEnv } from '../../utils/get-config-from-env.js';
|
|
11
19
|
import { getIPFromReq } from '../../utils/get-ip-from-req.js';
|
|
20
|
+
import isUrlAllowed from '../../utils/is-url-allowed.js';
|
|
21
|
+
import { verifyJWT } from '../../utils/jwt.js';
|
|
12
22
|
import { stall } from '../../utils/stall.js';
|
|
23
|
+
import { Url } from '../../utils/url.js';
|
|
13
24
|
import { AuthDriver } from '../auth.js';
|
|
25
|
+
/**
|
|
26
|
+
* Shortest gap between two confirmation mails for the same pending account. Without it, replaying
|
|
27
|
+
* `/register` for an address that is still awaiting confirmation would send a mail every time,
|
|
28
|
+
* which turns the endpoint into a way to flood a stranger's inbox.
|
|
29
|
+
*/
|
|
30
|
+
const REGISTER_MAIL_INTERVAL = 60000;
|
|
14
31
|
export class LocalAuthDriver extends AuthDriver {
|
|
15
32
|
async getUserID(payload) {
|
|
16
33
|
if (!payload['email']) {
|
|
@@ -34,9 +51,142 @@ export class LocalAuthDriver extends AuthDriver {
|
|
|
34
51
|
async login(user, payload) {
|
|
35
52
|
await this.verify(user, payload['password']);
|
|
36
53
|
}
|
|
54
|
+
getRegistrationService() {
|
|
55
|
+
// Registration is requested by an anonymous visitor, so the permission checks on the write
|
|
56
|
+
// have nothing to check against.
|
|
57
|
+
return new UsersService({
|
|
58
|
+
knex: this.knex,
|
|
59
|
+
schema: this.schema,
|
|
60
|
+
accountability: { role: null, admin: true },
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
async getAccountByEmail(email) {
|
|
64
|
+
return this.knex
|
|
65
|
+
.select('id', 'status', 'provider', 'auth_data')
|
|
66
|
+
.from('directus_users')
|
|
67
|
+
.whereRaw('LOWER(??) = ?', ['email', email.toLowerCase()])
|
|
68
|
+
.first();
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Build the confirmation link handed to a freshly registered user.
|
|
72
|
+
*
|
|
73
|
+
* Without a `verification_url` the link points back at this provider's own verify route, so the
|
|
74
|
+
* endpoint works with no frontend at all. With one, the token is appended to the caller's page,
|
|
75
|
+
* which is expected to forward it here.
|
|
76
|
+
*/
|
|
77
|
+
registerUrl(provider, email, url) {
|
|
78
|
+
const payload = { email, scope: 'register', provider };
|
|
79
|
+
const token = jwt.sign(payload, env['SECRET'], { expiresIn: '1d', issuer: 'directus' });
|
|
80
|
+
const verifyUrl = url
|
|
81
|
+
? new Url(url)
|
|
82
|
+
: new Url(env['PUBLIC_URL']).addPath('auth', 'login', provider, 'register', 'verify-email');
|
|
83
|
+
verifyUrl.setQuery('token', token);
|
|
84
|
+
return verifyUrl.toString();
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* When was the last confirmation mail for this pending account sent, if ever.
|
|
88
|
+
*/
|
|
89
|
+
lastMailSentAt(authData) {
|
|
90
|
+
let data = authData;
|
|
91
|
+
if (typeof data === 'string') {
|
|
92
|
+
try {
|
|
93
|
+
data = parseJSON(data);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const sentAt = data?.['registerMailSentAt'];
|
|
100
|
+
return sentAt ? Date.parse(sentAt) || 0 : 0;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Public self-service registration for this provider.
|
|
104
|
+
*
|
|
105
|
+
* The account is created `draft`, and d9 won't issue a token for a draft user, so nothing is
|
|
106
|
+
* reachable until the emailed link is redeemed by `verifyEmail`. An address that already has a
|
|
107
|
+
* non-draft account is ignored silently — the caller can't tell the two cases apart. An account
|
|
108
|
+
* still awaiting confirmation has its mail re-sent, which is how a client implements "resend"
|
|
109
|
+
* without a second route.
|
|
110
|
+
*/
|
|
111
|
+
async registerUser(provider, input) {
|
|
112
|
+
// Read here rather than held on the instance: this class is the base of the SSO drivers,
|
|
113
|
+
// which declare a `config` of their own.
|
|
114
|
+
const config = getConfigFromEnv(`AUTH_${provider.toUpperCase()}_`);
|
|
115
|
+
const url = input.verification_url ?? null;
|
|
116
|
+
if (url && isUrlAllowed(url, config['registerUrlAllowList']) === false) {
|
|
117
|
+
throw new InvalidPayloadException(`Url "${url}" can't be used to verify registrations.`);
|
|
118
|
+
}
|
|
119
|
+
const service = this.getRegistrationService();
|
|
120
|
+
const existing = await this.getAccountByEmail(input.email);
|
|
121
|
+
if (existing && (existing.status !== 'draft' || existing.provider.toLowerCase() !== provider.toLowerCase())) {
|
|
122
|
+
// Already confirmed, suspended/archived, or registered against a different provider.
|
|
123
|
+
// Send nothing, reveal nothing.
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (existing && Date.now() - this.lastMailSentAt(existing.auth_data) < REGISTER_MAIL_INTERVAL) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
// Not typed as `Partial<User>`: the type lists active|suspended|invited, while the schema also
|
|
130
|
+
// allows draft and archived (`system-data/fields/users.yaml:116`).
|
|
131
|
+
const userPayload = {
|
|
132
|
+
email: input.email,
|
|
133
|
+
password: input.password,
|
|
134
|
+
first_name: input.first_name ?? null,
|
|
135
|
+
last_name: input.last_name ?? null,
|
|
136
|
+
role: config['defaultRoleId'] ?? null,
|
|
137
|
+
status: 'draft',
|
|
138
|
+
// Login checks the user's provider against the one being logged in with
|
|
139
|
+
// (`services/authentication.ts:116`), so the column default would lock out anyone
|
|
140
|
+
// registered against a named local provider.
|
|
141
|
+
provider,
|
|
142
|
+
};
|
|
143
|
+
if (!existing) {
|
|
144
|
+
// Run hook so the end user has the chance to augment the
|
|
145
|
+
// user that is about to be created
|
|
146
|
+
const updatedUserPayload = await emitter.emitFilter(`auth.create`, userPayload, { identifier: input.email, provider, providerPayload: {} }, { database: getDatabase(), schema: this.schema, accountability: null });
|
|
147
|
+
await service.createOne(updatedUserPayload);
|
|
148
|
+
}
|
|
149
|
+
const account = existing ?? (await this.getAccountByEmail(input.email));
|
|
150
|
+
if (!account)
|
|
151
|
+
return;
|
|
152
|
+
const mailService = new MailService({ knex: this.knex, schema: this.schema });
|
|
153
|
+
await mailService.send({
|
|
154
|
+
to: input.email,
|
|
155
|
+
subject: 'Please confirm your email address',
|
|
156
|
+
template: {
|
|
157
|
+
name: 'user-registration',
|
|
158
|
+
data: {
|
|
159
|
+
url: this.registerUrl(provider, input.email, url),
|
|
160
|
+
email: input.email,
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
await service.updateOne(account.id, {
|
|
165
|
+
auth_data: JSON.stringify({ registerMailSentAt: new Date().toISOString() }),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Redeem a registration token and activate the account. Returns the user id so the route can
|
|
170
|
+
* redirect to it.
|
|
171
|
+
*
|
|
172
|
+
* A token whose account is no longer `draft` — a link clicked twice, or an account since
|
|
173
|
+
* suspended — is reported as invalid rather than replayed.
|
|
174
|
+
*/
|
|
175
|
+
async verifyEmail(provider, token) {
|
|
176
|
+
const { email, scope, provider: tokenProvider, } = verifyJWT(token, env['SECRET']);
|
|
177
|
+
if (scope !== 'register' || tokenProvider !== provider)
|
|
178
|
+
throw new ForbiddenException();
|
|
179
|
+
const account = await this.getAccountByEmail(email);
|
|
180
|
+
if (account?.status !== 'draft' || account.provider.toLowerCase() !== provider.toLowerCase()) {
|
|
181
|
+
throw new InvalidTokenException('Token invalid.');
|
|
182
|
+
}
|
|
183
|
+
await this.getRegistrationService().updateOne(account.id, { status: 'active', auth_data: null });
|
|
184
|
+
return account.id;
|
|
185
|
+
}
|
|
37
186
|
}
|
|
38
187
|
export function createLocalAuthRouter(provider) {
|
|
39
188
|
const router = Router();
|
|
189
|
+
const config = getConfigFromEnv(`AUTH_${provider.toUpperCase()}_`);
|
|
40
190
|
const userLoginSchema = Joi.object({
|
|
41
191
|
email: Joi.string().email().required(),
|
|
42
192
|
password: Joi.string().required(),
|
|
@@ -80,5 +230,46 @@ export function createLocalAuthRouter(provider) {
|
|
|
80
230
|
res.locals['payload'] = payload;
|
|
81
231
|
return next();
|
|
82
232
|
}), respond);
|
|
233
|
+
// The registration routes only exist where the provider is configured for it, so an instance
|
|
234
|
+
// that hasn't opted in doesn't expose them at all.
|
|
235
|
+
if (config['allowPublicRegistration']) {
|
|
236
|
+
const userRegisterSchema = Joi.object({
|
|
237
|
+
email: Joi.string().email().required(),
|
|
238
|
+
password: Joi.string().required(),
|
|
239
|
+
first_name: Joi.string().allow(null, ''),
|
|
240
|
+
last_name: Joi.string().allow(null, ''),
|
|
241
|
+
verification_url: Joi.string().uri(),
|
|
242
|
+
});
|
|
243
|
+
router.post('/register', asyncHandler(async (req, res) => {
|
|
244
|
+
const { error } = userRegisterSchema.validate(req.body);
|
|
245
|
+
if (error)
|
|
246
|
+
throw new InvalidPayloadException(error.message);
|
|
247
|
+
const driver = new LocalAuthDriver({ knex: getDatabase(), schema: req.schema }, config);
|
|
248
|
+
// Answer before doing any work. The response must not vary — in content or in timing —
|
|
249
|
+
// with whether the address is already registered, or the endpoint becomes a way to
|
|
250
|
+
// enumerate accounts.
|
|
251
|
+
res.status(204).end();
|
|
252
|
+
try {
|
|
253
|
+
await driver.registerUser(provider, {
|
|
254
|
+
email: req.body.email,
|
|
255
|
+
password: req.body.password,
|
|
256
|
+
first_name: req.body.first_name ?? null,
|
|
257
|
+
last_name: req.body.last_name ?? null,
|
|
258
|
+
verification_url: req.body.verification_url ?? null,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
logger.warn(err, `[register] Failed to register user for provider "${provider}"`);
|
|
263
|
+
}
|
|
264
|
+
}));
|
|
265
|
+
router.get('/register/verify-email', asyncHandler(async (req, res) => {
|
|
266
|
+
if (!req.query['token'] || typeof req.query['token'] !== 'string') {
|
|
267
|
+
throw new InvalidPayloadException(`"token" query parameter is required`);
|
|
268
|
+
}
|
|
269
|
+
const driver = new LocalAuthDriver({ knex: getDatabase(), schema: req.schema }, config);
|
|
270
|
+
const pk = await driver.verifyEmail(provider, req.query['token']);
|
|
271
|
+
return res.redirect(new Url(env['PUBLIC_URL']).addPath('admin', 'users', String(pk)).toString());
|
|
272
|
+
}));
|
|
273
|
+
}
|
|
83
274
|
return router;
|
|
84
275
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { format } from 'date-fns';
|
|
2
2
|
import { Router } from 'express';
|
|
3
|
+
import env from '../env.js';
|
|
3
4
|
import { RouteNotFoundException } from '../exceptions/index.js';
|
|
4
5
|
import { respond } from '../middleware/respond.js';
|
|
5
6
|
import { ServerService } from '../services/server.js';
|
|
@@ -15,6 +16,9 @@ router.get('/specs/oas', asyncHandler(async (req, res, next) => {
|
|
|
15
16
|
return next();
|
|
16
17
|
}), respond);
|
|
17
18
|
router.get('/specs/graphql/:scope?', asyncHandler(async (req, res) => {
|
|
19
|
+
if (env['SERVE_GRAPHQL'] === false) {
|
|
20
|
+
throw new RouteNotFoundException(req.path);
|
|
21
|
+
}
|
|
18
22
|
const service = new SpecificationService({
|
|
19
23
|
accountability: req.accountability,
|
|
20
24
|
schema: req.schema,
|
package/dist/env.js
CHANGED
|
@@ -22,6 +22,7 @@ const allowedEnvironmentVars = [
|
|
|
22
22
|
'MAX_PAYLOAD_SIZE',
|
|
23
23
|
'ROOT_REDIRECT',
|
|
24
24
|
'SERVE_APP',
|
|
25
|
+
'SERVE_GRAPHQL',
|
|
25
26
|
'GRAPHQL_INTROSPECTION',
|
|
26
27
|
'MAX_BATCH_MUTATION',
|
|
27
28
|
'LOGGER_.+',
|
|
@@ -139,6 +140,7 @@ const allowedEnvironmentVars = [
|
|
|
139
140
|
'AUTH_.+_LAST_NAME_KEY',
|
|
140
141
|
'AUTH_.+_ALLOW_PUBLIC_REGISTRATION',
|
|
141
142
|
'AUTH_.+_DEFAULT_ROLE_ID',
|
|
143
|
+
'AUTH_.+_REGISTER_URL_ALLOW_LIST',
|
|
142
144
|
'AUTH_.+_ICON',
|
|
143
145
|
'AUTH_.+_LABEL',
|
|
144
146
|
'AUTH_.+_PARAMS',
|
|
@@ -282,6 +284,7 @@ const defaults = {
|
|
|
282
284
|
IP_CUSTOM_HEADER: false,
|
|
283
285
|
IMPORT_IP_DENY_LIST: ['0.0.0.0', '169.254.169.254'],
|
|
284
286
|
SERVE_APP: true,
|
|
287
|
+
SERVE_GRAPHQL: true,
|
|
285
288
|
RELATIONAL_BATCH_SIZE: 25000,
|
|
286
289
|
EXPORT_BATCH_SIZE: 5000,
|
|
287
290
|
FILE_METADATA_ALLOW_LIST: 'ifd0.Make,ifd0.Model,exif.FNumber,exif.ExposureTime,exif.FocalLength,exif.ISO',
|
|
@@ -304,6 +307,7 @@ const typeMap = {
|
|
|
304
307
|
CACHE_SKIP_ALLOWED: 'boolean',
|
|
305
308
|
IMPORT_IP_DENY_LIST: 'array',
|
|
306
309
|
FILE_METADATA_ALLOW_LIST: 'array',
|
|
310
|
+
SERVE_GRAPHQL: 'boolean',
|
|
307
311
|
GRAPHQL_INTROSPECTION: 'boolean',
|
|
308
312
|
MAX_BATCH_MUTATION: 'number',
|
|
309
313
|
SERVER_SHUTDOWN_TIMEOUT: 'number',
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{% layout "base" %}
|
|
2
|
+
{% block content %}
|
|
3
|
+
|
|
4
|
+
<p>
|
|
5
|
+
Welcome to <i>{{ projectName }}</i>. Please click the button below to confirm this email address and activate your account:
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
<p style="text-align: center; padding: 20px 0;">
|
|
9
|
+
<a href="{{ url }}">
|
|
10
|
+
<b>Confirm your email</b>
|
|
11
|
+
</a>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<p>
|
|
15
|
+
If you didn't create this account, you can safely ignore this email.
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
<p>
|
|
19
|
+
Thank you,<br>
|
|
20
|
+
The {{ projectName }} Team
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
{% endblock %}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wbce-d9/api",
|
|
3
|
-
"version": "12.5.
|
|
3
|
+
"version": "12.5.5",
|
|
4
4
|
"description": "Directus is a real-time API and App dashboard for managing SQL database content",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -143,20 +143,20 @@
|
|
|
143
143
|
"tmp-promise": "3.0.3",
|
|
144
144
|
"tsx": "3.12.6",
|
|
145
145
|
"uuid-validate": "0.0.3",
|
|
146
|
-
"@wbce-d9/app": "10.1.1",
|
|
147
|
-
"@wbce-d9/constants": "10.0.0",
|
|
148
|
-
"@wbce-d9/extensions-sdk": "10.0.3",
|
|
149
146
|
"@wbce-d9/exceptions": "10.0.0",
|
|
150
147
|
"@wbce-d9/schema": "10.0.0",
|
|
151
148
|
"@wbce-d9/specs": "9.26.5",
|
|
152
149
|
"@wbce-d9/storage": "10.0.0",
|
|
150
|
+
"@wbce-d9/constants": "10.0.0",
|
|
153
151
|
"@wbce-d9/storage-driver-azure": "10.0.2",
|
|
154
152
|
"@wbce-d9/storage-driver-cloudinary": "10.0.3",
|
|
155
153
|
"@wbce-d9/storage-driver-local": "10.0.2",
|
|
156
154
|
"@wbce-d9/storage-driver-gcs": "10.0.2",
|
|
157
|
-
"@wbce-d9/storage-driver-s3": "10.0.2",
|
|
158
155
|
"@wbce-d9/update-check": "10.0.3",
|
|
159
|
-
"@wbce-d9/
|
|
156
|
+
"@wbce-d9/storage-driver-s3": "10.0.2",
|
|
157
|
+
"@wbce-d9/utils": "10.0.2",
|
|
158
|
+
"@wbce-d9/extensions-sdk": "10.0.3",
|
|
159
|
+
"@wbce-d9/app": "10.1.2"
|
|
160
160
|
},
|
|
161
161
|
"devDependencies": {
|
|
162
162
|
"@ngneat/falso": "6.4.0",
|