@easy-cms/core 0.22.2 → 0.24.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/dist/index.d.ts +126 -6
- package/dist/index.js +363 -29
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -56,6 +56,30 @@ declare const anyone: Access;
|
|
|
56
56
|
declare const isLoggedIn: Access;
|
|
57
57
|
declare const isAdmin: Access;
|
|
58
58
|
|
|
59
|
+
/** What the email functions in `auth.emails` receive. */
|
|
60
|
+
interface PasswordEmailArgs {
|
|
61
|
+
readonly user: AuthUser;
|
|
62
|
+
/** The page that sets the password; absent for `passwordChanged`. */
|
|
63
|
+
readonly url?: string;
|
|
64
|
+
/** The admin language for the text: `en` or `th`. */
|
|
65
|
+
readonly locale: string;
|
|
66
|
+
/** When the link stops working. */
|
|
67
|
+
readonly expiresAt?: Date;
|
|
68
|
+
}
|
|
69
|
+
/** An email's content: a subject, and text and/or HTML. */
|
|
70
|
+
interface PasswordEmail {
|
|
71
|
+
readonly subject: string;
|
|
72
|
+
readonly text: string;
|
|
73
|
+
readonly html?: string;
|
|
74
|
+
}
|
|
75
|
+
type PasswordEmailFn = (args: PasswordEmailArgs) => PasswordEmail | Promise<PasswordEmail>;
|
|
76
|
+
/** The default emails, in English and Thai. */
|
|
77
|
+
declare const DEFAULT_PASSWORD_EMAILS: {
|
|
78
|
+
resetPassword: PasswordEmailFn;
|
|
79
|
+
invite: PasswordEmailFn;
|
|
80
|
+
passwordChanged: PasswordEmailFn;
|
|
81
|
+
};
|
|
82
|
+
|
|
59
83
|
interface Logger {
|
|
60
84
|
info(message: string): void;
|
|
61
85
|
warn(message: string): void;
|
|
@@ -237,6 +261,8 @@ type PreviewTarget = {
|
|
|
237
261
|
} | {
|
|
238
262
|
readonly global: string;
|
|
239
263
|
};
|
|
264
|
+
/** Why a password link was sent: a forgotten password, or an invitation to a new account. */
|
|
265
|
+
type PasswordPurpose = 'reset' | 'invite';
|
|
240
266
|
|
|
241
267
|
interface LoginArgs {
|
|
242
268
|
readonly email: string;
|
|
@@ -252,6 +278,13 @@ interface Session {
|
|
|
252
278
|
readonly csrfToken: string;
|
|
253
279
|
readonly expiresAt: string;
|
|
254
280
|
}
|
|
281
|
+
/** Where a password link is opened from, and the language of its email. */
|
|
282
|
+
interface PasswordLinkOptions {
|
|
283
|
+
/** The request's origin: used for the link only in development, when `serverURL` is not set. */
|
|
284
|
+
readonly origin?: string | undefined;
|
|
285
|
+
/** `en` or `th`. Default: `admin.locale`. */
|
|
286
|
+
readonly locale?: string | undefined;
|
|
287
|
+
}
|
|
255
288
|
/** Login, logout and session verification. Available as `cms.auth`. */
|
|
256
289
|
declare class Auth {
|
|
257
290
|
private readonly cms;
|
|
@@ -280,6 +313,48 @@ declare class Auth {
|
|
|
280
313
|
createSession(userId: ID): Promise<Session>;
|
|
281
314
|
/** Signs a user out everywhere. */
|
|
282
315
|
revokeSessions(userId: ID): Promise<void>;
|
|
316
|
+
/**
|
|
317
|
+
* The origin password links point to: `serverURL`, or the request's origin in development. In
|
|
318
|
+
* production the request's Host can't be trusted (a forged one would send the link elsewhere).
|
|
319
|
+
*/
|
|
320
|
+
passwordLinkBase(origin?: string): string | null;
|
|
321
|
+
/** Whether "forgot password" and invitations work: `email` is set and links have an origin. */
|
|
322
|
+
canSendPasswordLinks(origin?: string): boolean;
|
|
323
|
+
/**
|
|
324
|
+
* Emails a link to set a new password, if an active account has this email. Always resolves the
|
|
325
|
+
* same way, so callers can't tell whether the email has an account; extra requests within
|
|
326
|
+
* `lockWindow` are ignored.
|
|
327
|
+
*/
|
|
328
|
+
requestPasswordReset(args: PasswordLinkOptions & {
|
|
329
|
+
email: string;
|
|
330
|
+
ip?: string | undefined;
|
|
331
|
+
}): Promise<void>;
|
|
332
|
+
/**
|
|
333
|
+
* Emails a user a link to set their password: an invitation if they have none yet, else a
|
|
334
|
+
* reset link. For admins (the REST API checks the role). Returns which one was sent.
|
|
335
|
+
*/
|
|
336
|
+
sendPasswordLink(userId: ID, options?: PasswordLinkOptions): Promise<PasswordPurpose>;
|
|
337
|
+
/** The user and purpose of a valid, unused password link, or `null`. */
|
|
338
|
+
checkPasswordToken(token: string): Promise<{
|
|
339
|
+
user: AuthUser;
|
|
340
|
+
purpose: PasswordPurpose;
|
|
341
|
+
} | null>;
|
|
342
|
+
/**
|
|
343
|
+
* Sets the password from a link, ends every session of the account and starts one here.
|
|
344
|
+
* Throws `ValidationError` when the link expired or was used, or the password is too short.
|
|
345
|
+
*/
|
|
346
|
+
resetPassword(args: {
|
|
347
|
+
token: string;
|
|
348
|
+
password: string;
|
|
349
|
+
ip?: string | undefined;
|
|
350
|
+
locale?: string | undefined;
|
|
351
|
+
}): Promise<Session & {
|
|
352
|
+
purpose: PasswordPurpose;
|
|
353
|
+
}>;
|
|
354
|
+
private passwordTokenUser;
|
|
355
|
+
private mailPasswordLink;
|
|
356
|
+
private localeOf;
|
|
357
|
+
private findByEmail;
|
|
283
358
|
/** The owner of an API key, limited by the key; `null` when unknown, expired or disabled. */
|
|
284
359
|
private verifyApiKey;
|
|
285
360
|
private startSession;
|
|
@@ -415,7 +490,9 @@ type FieldValue<F extends Field, C extends Config = Config> = F extends {
|
|
|
415
490
|
readonly type: 'richText';
|
|
416
491
|
} ? RichTextDocument : F extends {
|
|
417
492
|
readonly type: 'upload';
|
|
418
|
-
} ?
|
|
493
|
+
} ? F extends {
|
|
494
|
+
readonly hasMany: true;
|
|
495
|
+
} ? (ID | MediaDocument)[] : ID | MediaDocument : F extends {
|
|
419
496
|
readonly type: 'relationship';
|
|
420
497
|
readonly to: infer S;
|
|
421
498
|
} ? F extends {
|
|
@@ -504,7 +581,9 @@ type InputValue<F extends Field> = F extends {
|
|
|
504
581
|
readonly hasMany: true;
|
|
505
582
|
} ? Readonly<SelectValue<F>> : F extends {
|
|
506
583
|
readonly type: 'upload';
|
|
507
|
-
} ?
|
|
584
|
+
} ? F extends {
|
|
585
|
+
readonly hasMany: true;
|
|
586
|
+
} ? readonly ID[] : ID : F extends {
|
|
508
587
|
readonly type: 'date';
|
|
509
588
|
} ? string | Date : F extends {
|
|
510
589
|
readonly type: 'array';
|
|
@@ -537,14 +616,17 @@ type StatusInput<T> = T extends {
|
|
|
537
616
|
} ? {
|
|
538
617
|
status?: 'draft' | 'published';
|
|
539
618
|
} : unknown;
|
|
540
|
-
/**
|
|
619
|
+
/**
|
|
620
|
+
* A user's password, hashed and never returned. Optional when creating: a user without one sets
|
|
621
|
+
* it from an invitation link.
|
|
622
|
+
*/
|
|
541
623
|
type PasswordInput<S, Required extends boolean> = S extends 'users' ? Required extends true ? {
|
|
542
624
|
password: string;
|
|
543
625
|
} : {
|
|
544
626
|
password?: string;
|
|
545
627
|
} : unknown;
|
|
546
628
|
/** Data accepted by `create`, e.g. `CreateInput<typeof config, 'posts'>`. */
|
|
547
|
-
type CreateInput<C extends Config, S extends CollectionSlug<C>> = Simplify<FieldsInput<CollectionBySlug<C, S>['fields']> & StatusInput<CollectionBySlug<C, S>> & PasswordInput<S,
|
|
629
|
+
type CreateInput<C extends Config, S extends CollectionSlug<C>> = Simplify<FieldsInput<CollectionBySlug<C, S>['fields']> & StatusInput<CollectionBySlug<C, S>> & PasswordInput<S, false>>;
|
|
548
630
|
/** Data accepted by `update`: any subset of `CreateInput`. */
|
|
549
631
|
type UpdateInput<C extends Config, S extends CollectionSlug<C>> = Partial<CreateInput<C, S>>;
|
|
550
632
|
/** Data accepted by `updateGlobal`. */
|
|
@@ -1037,8 +1119,19 @@ interface RichTextDocument {
|
|
|
1037
1119
|
}
|
|
1038
1120
|
interface RichTextField extends BaseField<'richText', RichTextDocument> {
|
|
1039
1121
|
}
|
|
1040
|
-
/** References a document in the built-in `media` collection. */
|
|
1122
|
+
/** References a document in the built-in `media` collection: a file or an image. */
|
|
1041
1123
|
interface UploadField extends BaseField<'upload', never> {
|
|
1124
|
+
/** Several files, e.g. a gallery: the value is a list, in the order editors arrange. */
|
|
1125
|
+
readonly hasMany?: boolean;
|
|
1126
|
+
/** With `hasMany`: the fewest files. */
|
|
1127
|
+
readonly minRows?: number;
|
|
1128
|
+
/** With `hasMany`: the most files. */
|
|
1129
|
+
readonly maxRows?: number;
|
|
1130
|
+
/**
|
|
1131
|
+
* The file types allowed, e.g. `['image/*']` or `['image/*', 'application/pdf']`. The admin
|
|
1132
|
+
* offers only these, and saving refuses others.
|
|
1133
|
+
*/
|
|
1134
|
+
readonly mimeTypes?: readonly string[];
|
|
1042
1135
|
}
|
|
1043
1136
|
interface FilterOptionsArgs {
|
|
1044
1137
|
/** The document being edited; `undefined` while it is being created. */
|
|
@@ -1055,6 +1148,10 @@ interface RelationshipField extends BaseField<'relationship', never> {
|
|
|
1055
1148
|
/** Slug of the target collection. */
|
|
1056
1149
|
readonly to: string;
|
|
1057
1150
|
readonly hasMany?: boolean;
|
|
1151
|
+
/** With `hasMany`: the fewest documents. */
|
|
1152
|
+
readonly minRows?: number;
|
|
1153
|
+
/** With `hasMany`: the most documents. */
|
|
1154
|
+
readonly maxRows?: number;
|
|
1058
1155
|
readonly filterOptions?: FilterOptions;
|
|
1059
1156
|
}
|
|
1060
1157
|
interface ArrayField extends BaseField<'array', never> {
|
|
@@ -1084,6 +1181,10 @@ interface BlocksField extends BaseField<'blocks', never> {
|
|
|
1084
1181
|
readonly minRows?: number;
|
|
1085
1182
|
readonly maxRows?: number;
|
|
1086
1183
|
}
|
|
1184
|
+
/** Fields with `hasMany`: their value is a list of options, documents or files. */
|
|
1185
|
+
declare const isHasMany: (field: Field) => boolean;
|
|
1186
|
+
/** Whether a MIME type is one of `patterns` (`image/*` matches every image). */
|
|
1187
|
+
declare function mimeAllowedBy(mimeType: string, patterns: readonly string[]): boolean;
|
|
1087
1188
|
/** Fields whose value is a list of rows: `array` and `blocks`. */
|
|
1088
1189
|
declare const hasRows: (field: Field) => field is ArrayField | BlocksField;
|
|
1089
1190
|
/** The fields of one row of an `array` or `blocks` field (`undefined` for an unknown block). */
|
|
@@ -1347,6 +1448,19 @@ interface AuthConfig {
|
|
|
1347
1448
|
* Needed when the admin or frontend is served from another origin, or behind a proxy that rewrites the host.
|
|
1348
1449
|
*/
|
|
1349
1450
|
readonly trustedOrigins?: readonly string[];
|
|
1451
|
+
/**
|
|
1452
|
+
* How long a "forgot password" link works, in seconds. Default 1 hour. Links work once: setting
|
|
1453
|
+
* the password ends them. Needs `email` and, in production, `serverURL` for the link.
|
|
1454
|
+
*/
|
|
1455
|
+
readonly resetPasswordExpiration?: number;
|
|
1456
|
+
/** How long an invitation link works, in seconds. Default 7 days. */
|
|
1457
|
+
readonly inviteExpiration?: number;
|
|
1458
|
+
/** Your own text for the password emails, e.g. in your brand's voice. Default: English or Thai. */
|
|
1459
|
+
readonly emails?: {
|
|
1460
|
+
readonly resetPassword?: PasswordEmailFn;
|
|
1461
|
+
readonly invite?: PasswordEmailFn;
|
|
1462
|
+
readonly passwordChanged?: PasswordEmailFn;
|
|
1463
|
+
};
|
|
1350
1464
|
}
|
|
1351
1465
|
/** What an endpoint's handler receives. */
|
|
1352
1466
|
interface EndpointRequest {
|
|
@@ -1623,6 +1737,8 @@ interface Reference {
|
|
|
1623
1737
|
readonly id: ID;
|
|
1624
1738
|
/** The relationship's `filterOptions`, checked when saving. */
|
|
1625
1739
|
readonly filterOptions?: FilterOptions;
|
|
1740
|
+
/** The upload's `mimeTypes`, checked when saving. */
|
|
1741
|
+
readonly mimeTypes?: readonly string[];
|
|
1626
1742
|
}
|
|
1627
1743
|
interface ValidateOptions {
|
|
1628
1744
|
readonly operation: 'create' | 'update';
|
|
@@ -1732,6 +1848,8 @@ interface AdminField {
|
|
|
1732
1848
|
}[];
|
|
1733
1849
|
hasMany?: boolean;
|
|
1734
1850
|
to?: string;
|
|
1851
|
+
/** An upload's allowed file types, e.g. `image/*`. */
|
|
1852
|
+
mimeTypes?: string[];
|
|
1735
1853
|
/** A relationship with `filterOptions`: the picker asks the server which documents fit. */
|
|
1736
1854
|
filtered?: boolean;
|
|
1737
1855
|
from?: string;
|
|
@@ -1811,6 +1929,8 @@ interface AdminGlobal {
|
|
|
1811
1929
|
}
|
|
1812
1930
|
interface AdminSchema {
|
|
1813
1931
|
locale: AdminLocale;
|
|
1932
|
+
/** Admins can email users links to set their password (needs `email` and `serverURL`). */
|
|
1933
|
+
passwordLinks: boolean;
|
|
1814
1934
|
/** Menu order of collections (`admin.menu`); unlisted ones follow. */
|
|
1815
1935
|
menu: string[];
|
|
1816
1936
|
/** Content locales, when the config has `localization`. */
|
|
@@ -1859,4 +1979,4 @@ declare const BUILTIN_COLLECTIONS: readonly ["users", "media"];
|
|
|
1859
1979
|
/** Checks a config and returns every problem found. An empty array means it is valid. */
|
|
1860
1980
|
declare function validateConfig(config: Config): ConfigIssue[];
|
|
1861
1981
|
|
|
1862
|
-
export { ADMIN_ICONS, API_KEYS, API_KEY_GLOBAL_OPERATIONS, API_KEY_OPERATIONS, type Access, type AccessArgs, type AccessOptions, type AdminBrand, type AdminCollection, type AdminComponent, type AdminComponentRef, type AdminConfig, type AdminField, type AdminGlobal, type AdminIcon, type AdminLocale, type AdminSchema, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ApiKeyContext, type ApiKeyGlobalOperation, type ApiKeyOperation, type ApiKeyPermissions, type ArrayField, Auth, type AuthConfig, type AuthUser, BUILTIN_COLLECTIONS, type BeforeChangeHook, type BeforeDeleteHook, type BeforeValidateHook, type Block, type BlocksField, type BooleanField, type BuiltinApiKeysCollection, type BuiltinMediaCollection, type BuiltinUsersCollection, type BundleHints, CONFIG_FILE_NAMES, CSRF_COOKIE, CSRF_HEADER, type CliCommand, type CollectionAccess, type CollectionAdmin, type CollectionConfig, type CollectionDocument, type CollectionHooks, type CollectionListAdmin, type CollectionSlug, type Config, ConfigError, type ConfigIssue, type ContainerAdmin, type CopyProgress, type CopyResult, type CreateEasyCMSOptions, type CreateInput, type CreateMigrationResult, DEFAULT_ADMIN_PATH, DEFAULT_API_PATH, DEFAULT_DEPTH, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_VERSIONS, DEFAULT_TOKEN_EXPIRATION, type Database, type DatabaseAdapter, type DatabaseInitArgs, type DatabaseTransfer, type DateField, type DepthOptions, EXTENSIONS, EasyCMS, EasyCMSError, type EmailAdapter, type EmailField, type EmailMessage, type Endpoint, type EndpointRequest, FIELD_TYPES, type Field, type FieldAccess, type FieldAccessFn, type FieldAdmin, type FieldError, type FieldType, type FieldValidate, type FieldValidateContext, type FieldValue, type FieldsInput, type FieldsValue, type FilterOptions, type FilterOptionsArgs, type FindArgs, type FindOptions, ForbiddenError, type GlobalAccess, type GlobalConfig, type GlobalDocument, type GlobalHooks, type GlobalInput, type GlobalSlug, type GroupField, type ID, INTERNAL_COLLECTIONS, type ImageSize, type InferCollection, type InferGlobal, type JsonField, type Label, type LivePreview, type LoadConfigOptions, type LocalStorageOptions, type LocalizationConfig, type Logger, type LoginArgs, MAX_DEPTH, MEDIA, MIN_PASSWORD_LENGTH, MIN_SECRET_LENGTH, type MediaDocument, type MediaSize, type MigrationInfo, NotFoundError, type NumberField, type Operation, type PaginatedDocs, PayloadTooLargeError, type Plugin, type PreviewTarget, type PreviewURL, QueryError, type RawDocument, type ReadOptions, type RelationshipField, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoutesConfig, SESSION_COOKIE, type ScheduledJob, SchemaError, type SchemaMode, type SelectField, type SelectOption, type Session, type SlugField, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, UnauthorizedError, type UpdateInput, type UpdateOptions, type UploadConfig, type UploadField, ValidationError, type Version, type VersionSummary, type VersionsConfig, WEBHOOK_EVENTS, type WebhookConfig, type WebhookEvent, type WebhookPayload, type Where, type WhereOperators, anyone, applyDefaults, applyPlugins, configSignature, consoleEmail, consoleLogger, copyDatabase, createEasyCMS, createRestHandler, createRootEndpointHandler, defineConfig, fillMissing, findConfigFile, generateSlugs, generateTypes, hasRows, hashPassword, imageDimensions, importConfig, isAdmin, isLoggedIn, keyAllows, loadConfig, localStorage, mergeForUpdate, mimeAllowed, parseId, populate, readCookie, resolveAdminModule, resolveConfig, rowFields, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword };
|
|
1982
|
+
export { ADMIN_ICONS, API_KEYS, API_KEY_GLOBAL_OPERATIONS, API_KEY_OPERATIONS, type Access, type AccessArgs, type AccessOptions, type AdminBrand, type AdminCollection, type AdminComponent, type AdminComponentRef, type AdminConfig, type AdminField, type AdminGlobal, type AdminIcon, type AdminLocale, type AdminSchema, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ApiKeyContext, type ApiKeyGlobalOperation, type ApiKeyOperation, type ApiKeyPermissions, type ArrayField, Auth, type AuthConfig, type AuthUser, BUILTIN_COLLECTIONS, type BeforeChangeHook, type BeforeDeleteHook, type BeforeValidateHook, type Block, type BlocksField, type BooleanField, type BuiltinApiKeysCollection, type BuiltinMediaCollection, type BuiltinUsersCollection, type BundleHints, CONFIG_FILE_NAMES, CSRF_COOKIE, CSRF_HEADER, type CliCommand, type CollectionAccess, type CollectionAdmin, type CollectionConfig, type CollectionDocument, type CollectionHooks, type CollectionListAdmin, type CollectionSlug, type Config, ConfigError, type ConfigIssue, type ContainerAdmin, type CopyProgress, type CopyResult, type CreateEasyCMSOptions, type CreateInput, type CreateMigrationResult, DEFAULT_ADMIN_PATH, DEFAULT_API_PATH, DEFAULT_DEPTH, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_VERSIONS, DEFAULT_PASSWORD_EMAILS, DEFAULT_TOKEN_EXPIRATION, type Database, type DatabaseAdapter, type DatabaseInitArgs, type DatabaseTransfer, type DateField, type DepthOptions, EXTENSIONS, EasyCMS, EasyCMSError, type EmailAdapter, type EmailField, type EmailMessage, type Endpoint, type EndpointRequest, FIELD_TYPES, type Field, type FieldAccess, type FieldAccessFn, type FieldAdmin, type FieldError, type FieldType, type FieldValidate, type FieldValidateContext, type FieldValue, type FieldsInput, type FieldsValue, type FilterOptions, type FilterOptionsArgs, type FindArgs, type FindOptions, ForbiddenError, type GlobalAccess, type GlobalConfig, type GlobalDocument, type GlobalHooks, type GlobalInput, type GlobalSlug, type GroupField, type ID, INTERNAL_COLLECTIONS, type ImageSize, type InferCollection, type InferGlobal, type JsonField, type Label, type LivePreview, type LoadConfigOptions, type LocalStorageOptions, type LocalizationConfig, type Logger, type LoginArgs, MAX_DEPTH, MEDIA, MIN_PASSWORD_LENGTH, MIN_SECRET_LENGTH, type MediaDocument, type MediaSize, type MigrationInfo, NotFoundError, type NumberField, type Operation, type PaginatedDocs, type PasswordEmail, type PasswordEmailArgs, type PasswordEmailFn, type PasswordLinkOptions, PayloadTooLargeError, type Plugin, type PreviewTarget, type PreviewURL, QueryError, type RawDocument, type ReadOptions, type RelationshipField, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoutesConfig, SESSION_COOKIE, type ScheduledJob, SchemaError, type SchemaMode, type SelectField, type SelectOption, type Session, type SlugField, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, UnauthorizedError, type UpdateInput, type UpdateOptions, type UploadConfig, type UploadField, ValidationError, type Version, type VersionSummary, type VersionsConfig, WEBHOOK_EVENTS, type WebhookConfig, type WebhookEvent, type WebhookPayload, type Where, type WhereOperators, anyone, applyDefaults, applyPlugins, configSignature, consoleEmail, consoleLogger, copyDatabase, createEasyCMS, createRestHandler, createRootEndpointHandler, defineConfig, fillMissing, findConfigFile, generateSlugs, generateTypes, hasRows, hashPassword, imageDimensions, importConfig, isAdmin, isHasMany, isLoggedIn, keyAllows, loadConfig, localStorage, mergeForUpdate, mimeAllowed, mimeAllowedBy, parseId, populate, readCookie, resolveAdminModule, resolveConfig, rowFields, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword };
|
package/dist/index.js
CHANGED
|
@@ -209,6 +209,14 @@ var PayloadTooLargeError = class extends EasyCMSError {
|
|
|
209
209
|
};
|
|
210
210
|
|
|
211
211
|
// src/fields.ts
|
|
212
|
+
var isHasMany = (field) => (field.type === "select" || field.type === "relationship" || field.type === "upload") && field.hasMany === true;
|
|
213
|
+
function mimeAllowedBy(mimeType, patterns) {
|
|
214
|
+
const type = mimeType.toLowerCase();
|
|
215
|
+
return patterns.some((pattern) => {
|
|
216
|
+
const p = pattern.toLowerCase();
|
|
217
|
+
return p.endsWith("/*") ? type.startsWith(p.slice(0, -1)) : type === p;
|
|
218
|
+
});
|
|
219
|
+
}
|
|
212
220
|
var hasRows = (field) => field.type === "array" || field.type === "blocks";
|
|
213
221
|
function rowFields(field, row) {
|
|
214
222
|
if (field.type === "array") return field.fields;
|
|
@@ -552,6 +560,58 @@ var emailDeliveriesCollection = {
|
|
|
552
560
|
]
|
|
553
561
|
};
|
|
554
562
|
|
|
563
|
+
// src/auth/emails.ts
|
|
564
|
+
var esc = (value) => value.replace(
|
|
565
|
+
/[&<>"']/g,
|
|
566
|
+
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]
|
|
567
|
+
);
|
|
568
|
+
function email(subject, lines, button) {
|
|
569
|
+
const text = [...lines, ...button ? ["", button.url] : []].join("\n");
|
|
570
|
+
const html = `<div style="font-family:system-ui,sans-serif;font-size:15px;line-height:1.5;color:#1f2328">${lines.map((line) => `<p>${esc(line)}</p>`).join("")}${button ? `<p><a href="${esc(button.url)}" style="display:inline-block;padding:10px 18px;border-radius:8px;background:#2f6f5e;color:#fff;text-decoration:none;font-weight:600">${esc(button.label)}</a></p><p style="font-size:13px;color:#656d76">${esc(button.url)}</p>` : ""}</div>`;
|
|
571
|
+
return { subject, text, html };
|
|
572
|
+
}
|
|
573
|
+
var hours = (expiresAt) => Math.max(1, Math.round(((expiresAt?.getTime() ?? Date.now()) - Date.now()) / 36e5));
|
|
574
|
+
var days = (expiresAt) => Math.max(1, Math.round(hours(expiresAt) / 24));
|
|
575
|
+
var DEFAULT_PASSWORD_EMAILS = {
|
|
576
|
+
resetPassword: ({ url = "", locale, expiresAt }) => locale === "th" ? email(
|
|
577
|
+
"\u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E43\u0E2B\u0E21\u0E48",
|
|
578
|
+
[
|
|
579
|
+
"\u0E21\u0E35\u0E04\u0E33\u0E02\u0E2D\u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E43\u0E2B\u0E21\u0E48\u0E2A\u0E33\u0E2B\u0E23\u0E31\u0E1A\u0E1A\u0E31\u0E0D\u0E0A\u0E35\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13",
|
|
580
|
+
`\u0E25\u0E34\u0E07\u0E01\u0E4C\u0E19\u0E35\u0E49\u0E43\u0E0A\u0E49\u0E44\u0E14\u0E49\u0E04\u0E23\u0E31\u0E49\u0E07\u0E40\u0E14\u0E35\u0E22\u0E27\u0E20\u0E32\u0E22\u0E43\u0E19 ${hours(expiresAt)} \u0E0A\u0E31\u0E48\u0E27\u0E42\u0E21\u0E07 \u0E16\u0E49\u0E32\u0E04\u0E38\u0E13\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E02\u0E2D \u0E44\u0E21\u0E48\u0E15\u0E49\u0E2D\u0E07\u0E17\u0E33\u0E2D\u0E30\u0E44\u0E23 \u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E40\u0E14\u0E34\u0E21\u0E22\u0E31\u0E07\u0E43\u0E0A\u0E49\u0E44\u0E14\u0E49`
|
|
581
|
+
],
|
|
582
|
+
{ label: "\u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E43\u0E2B\u0E21\u0E48", url }
|
|
583
|
+
) : email(
|
|
584
|
+
"Reset your password",
|
|
585
|
+
[
|
|
586
|
+
"Someone asked to reset the password of your account.",
|
|
587
|
+
`The link works once, within ${hours(expiresAt)} hour(s). If it wasn't you, ignore this email: your password stays the same.`
|
|
588
|
+
],
|
|
589
|
+
{ label: "Set a new password", url }
|
|
590
|
+
),
|
|
591
|
+
invite: ({ url = "", locale, expiresAt, user }) => locale === "th" ? email(
|
|
592
|
+
"\u0E04\u0E38\u0E13\u0E44\u0E14\u0E49\u0E23\u0E31\u0E1A\u0E40\u0E0A\u0E34\u0E0D\u0E43\u0E2B\u0E49\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E23\u0E30\u0E1A\u0E1A\u0E08\u0E31\u0E14\u0E01\u0E32\u0E23\u0E40\u0E19\u0E37\u0E49\u0E2D\u0E2B\u0E32",
|
|
593
|
+
[
|
|
594
|
+
`\u0E21\u0E35\u0E1A\u0E31\u0E0D\u0E0A\u0E35\u0E2A\u0E33\u0E2B\u0E23\u0E31\u0E1A ${user.email} \u0E23\u0E2D\u0E04\u0E38\u0E13\u0E2D\u0E22\u0E39\u0E48 \u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E40\u0E23\u0E34\u0E48\u0E21\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19`,
|
|
595
|
+
`\u0E25\u0E34\u0E07\u0E01\u0E4C\u0E19\u0E35\u0E49\u0E43\u0E0A\u0E49\u0E44\u0E14\u0E49\u0E20\u0E32\u0E22\u0E43\u0E19 ${days(expiresAt)} \u0E27\u0E31\u0E19`
|
|
596
|
+
],
|
|
597
|
+
{ label: "\u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19", url }
|
|
598
|
+
) : email(
|
|
599
|
+
"You're invited to the content admin",
|
|
600
|
+
[
|
|
601
|
+
`An account for ${user.email} is waiting for you. Set a password to start.`,
|
|
602
|
+
`The link works within ${days(expiresAt)} day(s).`
|
|
603
|
+
],
|
|
604
|
+
{ label: "Set your password", url }
|
|
605
|
+
),
|
|
606
|
+
passwordChanged: ({ locale }) => locale === "th" ? email("\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13\u0E16\u0E39\u0E01\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E41\u0E25\u0E49\u0E27", [
|
|
607
|
+
"\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E02\u0E2D\u0E07\u0E1A\u0E31\u0E0D\u0E0A\u0E35\u0E04\u0E38\u0E13\u0E40\u0E1E\u0E34\u0E48\u0E07\u0E16\u0E39\u0E01\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19 \u0E41\u0E25\u0E30\u0E17\u0E38\u0E01\u0E40\u0E04\u0E23\u0E37\u0E48\u0E2D\u0E07\u0E17\u0E35\u0E48 login \u0E2D\u0E22\u0E39\u0E48\u0E16\u0E39\u0E01\u0E2D\u0E2D\u0E01\u0E08\u0E32\u0E01\u0E23\u0E30\u0E1A\u0E1A\u0E41\u0E25\u0E49\u0E27",
|
|
608
|
+
"\u0E16\u0E49\u0E32\u0E04\u0E38\u0E13\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E40\u0E2D\u0E07 \u0E43\u0E2B\u0E49\u0E02\u0E2D\u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E43\u0E2B\u0E21\u0E48\u0E17\u0E31\u0E19\u0E17\u0E35\u0E41\u0E25\u0E30\u0E41\u0E08\u0E49\u0E07\u0E1C\u0E39\u0E49\u0E14\u0E39\u0E41\u0E25\u0E23\u0E30\u0E1A\u0E1A"
|
|
609
|
+
]) : email("Your password was changed", [
|
|
610
|
+
"The password of your account was just changed, and every device that was logged in has been signed out.",
|
|
611
|
+
"If it wasn't you, reset your password now and tell your administrator."
|
|
612
|
+
])
|
|
613
|
+
};
|
|
614
|
+
|
|
555
615
|
// src/auth/password.ts
|
|
556
616
|
import { randomBytes as randomBytes2, scrypt, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
557
617
|
var MIN_PASSWORD_LENGTH = 8;
|
|
@@ -649,9 +709,38 @@ function verifyPreviewToken(secret, token, now = Date.now()) {
|
|
|
649
709
|
return null;
|
|
650
710
|
}
|
|
651
711
|
}
|
|
712
|
+
function passwordFingerprint(passwordHash) {
|
|
713
|
+
return createHash2("sha256").update(typeof passwordHash === "string" ? passwordHash : "no-password").digest("base64url").slice(0, 22);
|
|
714
|
+
}
|
|
715
|
+
function signPasswordToken(secret, args) {
|
|
716
|
+
const payload = Buffer.from(
|
|
717
|
+
JSON.stringify({ u: args.userId, p: args.purpose, e: args.expiresAt })
|
|
718
|
+
).toString("base64url");
|
|
719
|
+
return `${payload}.${hmac(secret, `password:${payload}:${args.fingerprint}`)}`;
|
|
720
|
+
}
|
|
721
|
+
function readPasswordToken(token) {
|
|
722
|
+
const dot = token.lastIndexOf(".");
|
|
723
|
+
if (dot <= 0) return null;
|
|
724
|
+
const payload = token.slice(0, dot);
|
|
725
|
+
try {
|
|
726
|
+
const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
727
|
+
if (typeof data.u !== "string" || typeof data.e !== "number") return null;
|
|
728
|
+
if (data.p !== "reset" && data.p !== "invite") return null;
|
|
729
|
+
return { userId: data.u, purpose: data.p, expiresAt: data.e, payload };
|
|
730
|
+
} catch {
|
|
731
|
+
return null;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
function passwordTokenMatches(secret, token, fingerprint) {
|
|
735
|
+
const dot = token.lastIndexOf(".");
|
|
736
|
+
if (dot <= 0) return false;
|
|
737
|
+
const payload = token.slice(0, dot);
|
|
738
|
+
return safeEqual(token.slice(dot + 1), hmac(secret, `password:${payload}:${fingerprint}`));
|
|
739
|
+
}
|
|
652
740
|
|
|
653
741
|
// src/auth/auth.ts
|
|
654
742
|
var INVALID = "Invalid email or password";
|
|
743
|
+
var RESET_REQUESTS = 3;
|
|
655
744
|
var Auth = class {
|
|
656
745
|
constructor(cms) {
|
|
657
746
|
this.cms = cms;
|
|
@@ -665,13 +754,13 @@ var Auth = class {
|
|
|
665
754
|
}
|
|
666
755
|
/** Checks credentials and starts a session. Throws `UnauthorizedError` or `TooManyRequestsError`. */
|
|
667
756
|
async login(args) {
|
|
668
|
-
const
|
|
757
|
+
const email2 = typeof args.email === "string" ? args.email.trim().toLowerCase() : "";
|
|
669
758
|
const password = typeof args.password === "string" ? args.password : "";
|
|
670
|
-
const key2 = `${
|
|
759
|
+
const key2 = `${email2}|${args.ip ?? ""}`;
|
|
671
760
|
await this.checkRateLimit(key2);
|
|
672
|
-
const [user] =
|
|
761
|
+
const [user] = email2 ? (await this.db.find({
|
|
673
762
|
collection: USERS,
|
|
674
|
-
where: { email: { equals:
|
|
763
|
+
where: { email: { equals: email2 } },
|
|
675
764
|
sort: [],
|
|
676
765
|
limit: 1,
|
|
677
766
|
page: 1
|
|
@@ -751,6 +840,131 @@ var Auth = class {
|
|
|
751
840
|
for (const session of sessions.docs)
|
|
752
841
|
await this.db.delete({ collection: SESSIONS, id: session.id });
|
|
753
842
|
}
|
|
843
|
+
// --- Password links: forgotten passwords and invitations ------------------------------------
|
|
844
|
+
/**
|
|
845
|
+
* The origin password links point to: `serverURL`, or the request's origin in development. In
|
|
846
|
+
* production the request's Host can't be trusted (a forged one would send the link elsewhere).
|
|
847
|
+
*/
|
|
848
|
+
passwordLinkBase(origin) {
|
|
849
|
+
const configured = this.config.serverURL;
|
|
850
|
+
if (configured) return configured.replace(/\/+$/, "");
|
|
851
|
+
if (process.env.NODE_ENV !== "production" && origin) return origin;
|
|
852
|
+
return null;
|
|
853
|
+
}
|
|
854
|
+
/** Whether "forgot password" and invitations work: `email` is set and links have an origin. */
|
|
855
|
+
canSendPasswordLinks(origin) {
|
|
856
|
+
return this.config.email !== void 0 && this.passwordLinkBase(origin) !== null;
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* Emails a link to set a new password, if an active account has this email. Always resolves the
|
|
860
|
+
* same way, so callers can't tell whether the email has an account; extra requests within
|
|
861
|
+
* `lockWindow` are ignored.
|
|
862
|
+
*/
|
|
863
|
+
async requestPasswordReset(args) {
|
|
864
|
+
const email2 = typeof args.email === "string" ? args.email.trim().toLowerCase() : "";
|
|
865
|
+
if (!email2 || !this.canSendPasswordLinks(args.origin)) return;
|
|
866
|
+
const key2 = `reset:${email2}|${args.ip ?? ""}`;
|
|
867
|
+
const recent = await this.db.count({
|
|
868
|
+
collection: LOGIN_ATTEMPTS,
|
|
869
|
+
where: { key: { equals: key2 }, createdAt: { gt: this.windowStart() } }
|
|
870
|
+
});
|
|
871
|
+
if (recent >= RESET_REQUESTS) return;
|
|
872
|
+
await this.recordFailure(key2);
|
|
873
|
+
const user = await this.findByEmail(email2);
|
|
874
|
+
if (!user || user.active === false) return;
|
|
875
|
+
await this.mailPasswordLink(user, user.passwordHash ? "reset" : "invite", args);
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Emails a user a link to set their password: an invitation if they have none yet, else a
|
|
879
|
+
* reset link. For admins (the REST API checks the role). Returns which one was sent.
|
|
880
|
+
*/
|
|
881
|
+
async sendPasswordLink(userId, options = {}) {
|
|
882
|
+
if (!this.canSendPasswordLinks(options.origin))
|
|
883
|
+
throw new QueryError(
|
|
884
|
+
"Password links need `email` in the config, and `serverURL` in production"
|
|
885
|
+
);
|
|
886
|
+
const user = await this.db.findById({ collection: USERS, id: userId });
|
|
887
|
+
if (!user) throw new NotFoundError(USERS, userId);
|
|
888
|
+
if (user.active === false) throw new QueryError("This user is deactivated");
|
|
889
|
+
const purpose = user.passwordHash ? "reset" : "invite";
|
|
890
|
+
await this.mailPasswordLink(user, purpose, options);
|
|
891
|
+
return purpose;
|
|
892
|
+
}
|
|
893
|
+
/** The user and purpose of a valid, unused password link, or `null`. */
|
|
894
|
+
async checkPasswordToken(token) {
|
|
895
|
+
const found = await this.passwordTokenUser(token);
|
|
896
|
+
return found ? { user: await this.toAuthUser(found.user), purpose: found.purpose } : null;
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Sets the password from a link, ends every session of the account and starts one here.
|
|
900
|
+
* Throws `ValidationError` when the link expired or was used, or the password is too short.
|
|
901
|
+
*/
|
|
902
|
+
async resetPassword(args) {
|
|
903
|
+
const key2 = `reset-token|${args.ip ?? ""}`;
|
|
904
|
+
await this.checkRateLimit(key2);
|
|
905
|
+
const found = await this.passwordTokenUser(String(args.token ?? ""));
|
|
906
|
+
if (!found) {
|
|
907
|
+
await this.recordFailure(key2);
|
|
908
|
+
throw new ValidationError(USERS, [
|
|
909
|
+
{ field: "token", message: "This link has expired or was already used" }
|
|
910
|
+
]);
|
|
911
|
+
}
|
|
912
|
+
await this.cms.update(USERS, found.user.id, { password: args.password });
|
|
913
|
+
await this.clearFailures(key2);
|
|
914
|
+
const user = await this.db.findById({ collection: USERS, id: found.user.id });
|
|
915
|
+
const session = await this.startSession(user);
|
|
916
|
+
if (found.purpose === "reset") {
|
|
917
|
+
const content = await (this.config.auth.emails.passwordChanged ?? DEFAULT_PASSWORD_EMAILS.passwordChanged)({
|
|
918
|
+
user: await this.toAuthUser(user),
|
|
919
|
+
locale: this.localeOf(args.locale)
|
|
920
|
+
});
|
|
921
|
+
await this.cms.sendEmail({ to: String(user.email), ...content });
|
|
922
|
+
}
|
|
923
|
+
return { ...session, purpose: found.purpose };
|
|
924
|
+
}
|
|
925
|
+
async passwordTokenUser(token) {
|
|
926
|
+
const claims = readPasswordToken(token);
|
|
927
|
+
if (!claims || claims.expiresAt <= Date.now()) return null;
|
|
928
|
+
const id = /^\d+$/.test(claims.userId) ? Number(claims.userId) : claims.userId;
|
|
929
|
+
const user = await this.db.findById({ collection: USERS, id });
|
|
930
|
+
if (!user || user.active === false) return null;
|
|
931
|
+
const fingerprint = passwordFingerprint(user.passwordHash);
|
|
932
|
+
if (!passwordTokenMatches(this.config.secret, token, fingerprint)) return null;
|
|
933
|
+
return { user, purpose: claims.purpose };
|
|
934
|
+
}
|
|
935
|
+
async mailPasswordLink(user, purpose, options) {
|
|
936
|
+
const base = this.passwordLinkBase(options.origin);
|
|
937
|
+
const seconds = purpose === "invite" ? this.config.auth.inviteExpiration : this.config.auth.resetPasswordExpiration;
|
|
938
|
+
const expiresAt = Date.now() + seconds * 1e3;
|
|
939
|
+
const token = signPasswordToken(this.config.secret, {
|
|
940
|
+
userId: String(user.id),
|
|
941
|
+
purpose,
|
|
942
|
+
expiresAt,
|
|
943
|
+
fingerprint: passwordFingerprint(user.passwordHash)
|
|
944
|
+
});
|
|
945
|
+
const url = `${base}${this.config.admin.path}/reset-password?token=${encodeURIComponent(token)}`;
|
|
946
|
+
const write = purpose === "invite" ? this.config.auth.emails.invite ?? DEFAULT_PASSWORD_EMAILS.invite : this.config.auth.emails.resetPassword ?? DEFAULT_PASSWORD_EMAILS.resetPassword;
|
|
947
|
+
const content = await write({
|
|
948
|
+
user: await this.toAuthUser(user),
|
|
949
|
+
url,
|
|
950
|
+
locale: this.localeOf(options.locale),
|
|
951
|
+
expiresAt: new Date(expiresAt)
|
|
952
|
+
});
|
|
953
|
+
await this.cms.sendEmail({ to: String(user.email), ...content });
|
|
954
|
+
}
|
|
955
|
+
localeOf(locale) {
|
|
956
|
+
return locale === "th" || locale === "en" ? locale : this.config.admin.locale;
|
|
957
|
+
}
|
|
958
|
+
async findByEmail(email2) {
|
|
959
|
+
const { docs } = await this.db.find({
|
|
960
|
+
collection: USERS,
|
|
961
|
+
where: { email: { equals: email2 } },
|
|
962
|
+
sort: [],
|
|
963
|
+
limit: 1,
|
|
964
|
+
page: 1
|
|
965
|
+
});
|
|
966
|
+
return docs[0];
|
|
967
|
+
}
|
|
754
968
|
/** The owner of an API key, limited by the key; `null` when unknown, expired or disabled. */
|
|
755
969
|
async verifyApiKey(token) {
|
|
756
970
|
if (!this.cms.config.collections.some((c) => c.slug === API_KEYS)) return null;
|
|
@@ -997,6 +1211,10 @@ async function copyDatabase(source, target, options = {}) {
|
|
|
997
1211
|
import { randomUUID } from "crypto";
|
|
998
1212
|
var EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
999
1213
|
var isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1214
|
+
function itemsOf(field, n) {
|
|
1215
|
+
const unit = field.type === "upload" ? "file" : field.type === "relationship" ? "document" : "row";
|
|
1216
|
+
return `${n} ${unit}${n === 1 ? "" : "s"}`;
|
|
1217
|
+
}
|
|
1000
1218
|
var isEmpty = (value) => value === void 0 || value === null || value === "";
|
|
1001
1219
|
function slugify(text) {
|
|
1002
1220
|
return text.normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{M}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "");
|
|
@@ -1162,6 +1380,8 @@ async function validateFields(fields, input, options, prefix = "") {
|
|
|
1162
1380
|
}
|
|
1163
1381
|
if (isEmpty(raw) || Array.isArray(raw) && raw.length === 0) {
|
|
1164
1382
|
if (field.required && !options.skipRequired) fail("is required");
|
|
1383
|
+
else if (!options.skipRequired && "minRows" in field && field.minRows)
|
|
1384
|
+
fail(`must have at least ${itemsOf(field, field.minRows)}`);
|
|
1165
1385
|
data[field.name] = hasRows(field) || isHasMany(field) ? [] : null;
|
|
1166
1386
|
if (!field.required) await runCustom(field, null, options, fail);
|
|
1167
1387
|
continue;
|
|
@@ -1173,9 +1393,6 @@ async function validateFields(fields, input, options, prefix = "") {
|
|
|
1173
1393
|
}
|
|
1174
1394
|
return { data, errors, references };
|
|
1175
1395
|
}
|
|
1176
|
-
function isHasMany(field) {
|
|
1177
|
-
return (field.type === "select" || field.type === "relationship") && field.hasMany === true;
|
|
1178
|
-
}
|
|
1179
1396
|
async function runCustom(field, value, options, fail) {
|
|
1180
1397
|
if (!field.validate) return;
|
|
1181
1398
|
const validate = field.validate;
|
|
@@ -1231,9 +1448,13 @@ async function normalizeValue(field, raw, path, options, fail, errors, reference
|
|
|
1231
1448
|
case "upload":
|
|
1232
1449
|
case "relationship": {
|
|
1233
1450
|
const target = field.type === "upload" ? "media" : field.to;
|
|
1234
|
-
const many = field.
|
|
1451
|
+
const many = field.hasMany === true;
|
|
1235
1452
|
const values = many ? raw : [raw];
|
|
1236
1453
|
if (!Array.isArray(values)) return fail("must be an array of ids");
|
|
1454
|
+
if (many && field.minRows !== void 0 && values.length < field.minRows)
|
|
1455
|
+
return fail(`must have at least ${itemsOf(field, field.minRows)}`);
|
|
1456
|
+
if (many && field.maxRows !== void 0 && values.length > field.maxRows)
|
|
1457
|
+
return fail(`must have at most ${itemsOf(field, field.maxRows)}`);
|
|
1237
1458
|
const ids = [];
|
|
1238
1459
|
for (const item of values) {
|
|
1239
1460
|
const id = parseId(isPlainObject(item) ? item.id : item);
|
|
@@ -1244,7 +1465,8 @@ async function normalizeValue(field, raw, path, options, fail, errors, reference
|
|
|
1244
1465
|
field: path,
|
|
1245
1466
|
collection: target,
|
|
1246
1467
|
id,
|
|
1247
|
-
...field.type === "relationship" && field.filterOptions ? { filterOptions: field.filterOptions } : {}
|
|
1468
|
+
...field.type === "relationship" && field.filterOptions ? { filterOptions: field.filterOptions } : {},
|
|
1469
|
+
...field.type === "upload" && field.mimeTypes ? { mimeTypes: field.mimeTypes } : {}
|
|
1248
1470
|
});
|
|
1249
1471
|
}
|
|
1250
1472
|
return many ? ids : ids[0];
|
|
@@ -1785,12 +2007,36 @@ function validateAuth(config, add) {
|
|
|
1785
2007
|
add("auth.roles", 'must include "admin"', "e.g. roles: ['admin', 'editor']");
|
|
1786
2008
|
}
|
|
1787
2009
|
}
|
|
1788
|
-
for (const key2 of [
|
|
2010
|
+
for (const key2 of [
|
|
2011
|
+
"tokenExpiration",
|
|
2012
|
+
"maxLoginAttempts",
|
|
2013
|
+
"lockWindow",
|
|
2014
|
+
"resetPasswordExpiration",
|
|
2015
|
+
"inviteExpiration"
|
|
2016
|
+
]) {
|
|
1789
2017
|
const value = auth[key2];
|
|
1790
2018
|
if (value !== void 0 && !(Number.isInteger(value) && value > 0)) {
|
|
1791
2019
|
add(`auth.${key2}`, "must be a positive integer");
|
|
1792
2020
|
}
|
|
1793
2021
|
}
|
|
2022
|
+
if (auth.emails !== void 0) {
|
|
2023
|
+
const emails = auth.emails;
|
|
2024
|
+
if (typeof emails !== "object" || emails === null) add("auth.emails", "must be an object");
|
|
2025
|
+
else
|
|
2026
|
+
for (const [name, fn] of Object.entries(emails)) {
|
|
2027
|
+
if (!["resetPassword", "invite", "passwordChanged"].includes(name))
|
|
2028
|
+
add(
|
|
2029
|
+
`auth.emails.${name}`,
|
|
2030
|
+
"unknown email",
|
|
2031
|
+
"use resetPassword, invite or passwordChanged"
|
|
2032
|
+
);
|
|
2033
|
+
else if (typeof fn !== "function")
|
|
2034
|
+
add(
|
|
2035
|
+
`auth.emails.${name}`,
|
|
2036
|
+
"must be a function ({ user, url, locale }) => { subject, text }"
|
|
2037
|
+
);
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
1794
2040
|
if (auth.trustedOrigins !== void 0) {
|
|
1795
2041
|
for (const [i, origin] of auth.trustedOrigins.entries()) {
|
|
1796
2042
|
if (typeof origin !== "string" || !/^https?:\/\/[^/]+$/.test(origin)) {
|
|
@@ -1882,8 +2128,8 @@ function validateLocalization(config, add) {
|
|
|
1882
2128
|
for (const [i, g] of (config.globals ?? []).entries()) walk(g?.fields, `globals[${i}].fields`);
|
|
1883
2129
|
}
|
|
1884
2130
|
function validateWebhooks(config, add) {
|
|
1885
|
-
const
|
|
1886
|
-
if (
|
|
2131
|
+
const email2 = config.email;
|
|
2132
|
+
if (email2 !== void 0 && (typeof email2 !== "object" || email2 === null || typeof email2.send !== "function"))
|
|
1887
2133
|
add(
|
|
1888
2134
|
"email",
|
|
1889
2135
|
"must be an email adapter with a send() function",
|
|
@@ -2060,6 +2306,13 @@ function validateList(list, container, path, add) {
|
|
|
2060
2306
|
);
|
|
2061
2307
|
}
|
|
2062
2308
|
}
|
|
2309
|
+
function validateManyRange(field, path, add) {
|
|
2310
|
+
if ((field.minRows !== void 0 || field.maxRows !== void 0) && !field.hasMany) {
|
|
2311
|
+
add(path, "minRows and maxRows need hasMany: true");
|
|
2312
|
+
return;
|
|
2313
|
+
}
|
|
2314
|
+
checkRange(field.minRows, field.maxRows, "minRows", "maxRows", path, add);
|
|
2315
|
+
}
|
|
2063
2316
|
function validateField(field, path, siblings, collectionSlugs, add) {
|
|
2064
2317
|
const position = field.position;
|
|
2065
2318
|
if (position !== void 0 && position !== "sidebar") {
|
|
@@ -2115,6 +2368,16 @@ function validateField(field, path, siblings, collectionSlugs, add) {
|
|
|
2115
2368
|
`use one of: ${[...collectionSlugs].join(", ")}`
|
|
2116
2369
|
);
|
|
2117
2370
|
}
|
|
2371
|
+
validateManyRange(field, path, add);
|
|
2372
|
+
break;
|
|
2373
|
+
case "upload":
|
|
2374
|
+
validateManyRange(field, path, add);
|
|
2375
|
+
if (field.mimeTypes !== void 0) {
|
|
2376
|
+
const types = field.mimeTypes;
|
|
2377
|
+
if (!Array.isArray(types) || types.length === 0 || !types.every((t) => typeof t === "string" && /^[\w.+-]+\/(\*|[\w.+-]+)$/.test(t))) {
|
|
2378
|
+
add(`${path}.mimeTypes`, "must be a list of MIME types", "e.g. mimeTypes: ['image/*']");
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2118
2381
|
break;
|
|
2119
2382
|
case "array":
|
|
2120
2383
|
case "group":
|
|
@@ -2367,7 +2630,10 @@ async function resolveConfig(input) {
|
|
|
2367
2630
|
tokenExpiration: config.auth?.tokenExpiration ?? DEFAULT_TOKEN_EXPIRATION,
|
|
2368
2631
|
maxLoginAttempts: config.auth?.maxLoginAttempts ?? 5,
|
|
2369
2632
|
lockWindow: config.auth?.lockWindow ?? 15 * 60,
|
|
2370
|
-
trustedOrigins: config.auth?.trustedOrigins ?? []
|
|
2633
|
+
trustedOrigins: config.auth?.trustedOrigins ?? [],
|
|
2634
|
+
resetPasswordExpiration: config.auth?.resetPasswordExpiration ?? 60 * 60,
|
|
2635
|
+
inviteExpiration: config.auth?.inviteExpiration ?? 7 * 24 * 60 * 60,
|
|
2636
|
+
emails: config.auth?.emails ?? {}
|
|
2371
2637
|
},
|
|
2372
2638
|
collections: [
|
|
2373
2639
|
...config.collections ?? [],
|
|
@@ -3612,18 +3878,18 @@ var EasyCMS = class {
|
|
|
3612
3878
|
/** Emails queued until sent, stored in `email-deliveries`. */
|
|
3613
3879
|
emailQueue() {
|
|
3614
3880
|
const collection2 = EMAIL_DELIVERIES;
|
|
3615
|
-
const toRow = (
|
|
3881
|
+
const toRow = (email2, createdAt) => {
|
|
3616
3882
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3617
|
-
return { ...
|
|
3883
|
+
return { ...email2, createdAt: createdAt ?? now, updatedAt: now };
|
|
3618
3884
|
};
|
|
3619
3885
|
return {
|
|
3620
|
-
add: async (
|
|
3621
|
-
update: async (id,
|
|
3886
|
+
add: async (email2) => (await this.db.create({ collection: collection2, data: toRow(email2) })).id,
|
|
3887
|
+
update: async (id, email2) => {
|
|
3622
3888
|
const current = await this.db.findById({ collection: collection2, id });
|
|
3623
3889
|
await this.db.update({
|
|
3624
3890
|
collection: collection2,
|
|
3625
3891
|
id,
|
|
3626
|
-
data: toRow(
|
|
3892
|
+
data: toRow(email2, current?.createdAt)
|
|
3627
3893
|
});
|
|
3628
3894
|
},
|
|
3629
3895
|
remove: async (id) => {
|
|
@@ -4008,9 +4274,6 @@ var EasyCMS = class {
|
|
|
4008
4274
|
if (!allowed2) throw deny(guard.user);
|
|
4009
4275
|
}
|
|
4010
4276
|
const { input, password } = splitPassword(config, raw);
|
|
4011
|
-
if (config.slug === USERS && password === void 0) {
|
|
4012
|
-
throw new ValidationError(collection2, [{ field: "password", message: "is required" }]);
|
|
4013
|
-
}
|
|
4014
4277
|
const filtered = await filterInput(
|
|
4015
4278
|
config.fields,
|
|
4016
4279
|
input,
|
|
@@ -4343,10 +4606,20 @@ var EasyCMS = class {
|
|
|
4343
4606
|
collection: collection2,
|
|
4344
4607
|
ids: [...new Set(refs.map((r) => r.id))]
|
|
4345
4608
|
});
|
|
4346
|
-
const
|
|
4609
|
+
const byId = new Map(found.map((d) => [d.id, d]));
|
|
4347
4610
|
for (const ref of refs) {
|
|
4348
|
-
|
|
4611
|
+
const doc = byId.get(ref.id);
|
|
4612
|
+
if (!doc) {
|
|
4349
4613
|
errors.push({ field: ref.field, message: `${collection2} ${ref.id} does not exist` });
|
|
4614
|
+
continue;
|
|
4615
|
+
}
|
|
4616
|
+
const type = typeof doc.mimeType === "string" ? doc.mimeType : "";
|
|
4617
|
+
if (ref.mimeTypes && !mimeAllowedBy(type, ref.mimeTypes)) {
|
|
4618
|
+
const images = ref.mimeTypes.every((t) => t.toLowerCase() === "image/*");
|
|
4619
|
+
errors.push({
|
|
4620
|
+
field: ref.field,
|
|
4621
|
+
message: images ? `must be an image (${doc.filename ?? ref.id} is ${type || "not one"})` : `must be ${ref.mimeTypes.join(", ")} (${doc.filename ?? ref.id} is ${type || "unknown"})`
|
|
4622
|
+
});
|
|
4350
4623
|
}
|
|
4351
4624
|
}
|
|
4352
4625
|
}
|
|
@@ -4492,10 +4765,16 @@ async function serializeFields(fields, update, localized) {
|
|
|
4492
4765
|
case "relationship":
|
|
4493
4766
|
f.to = field.to;
|
|
4494
4767
|
if (field.hasMany) f.hasMany = true;
|
|
4768
|
+
if (field.minRows !== void 0) f.minRows = field.minRows;
|
|
4769
|
+
if (field.maxRows !== void 0) f.maxRows = field.maxRows;
|
|
4495
4770
|
if (field.filterOptions) f.filtered = true;
|
|
4496
4771
|
break;
|
|
4497
4772
|
case "upload":
|
|
4498
4773
|
f.to = "media";
|
|
4774
|
+
if (field.hasMany) f.hasMany = true;
|
|
4775
|
+
if (field.minRows !== void 0) f.minRows = field.minRows;
|
|
4776
|
+
if (field.maxRows !== void 0) f.maxRows = field.maxRows;
|
|
4777
|
+
if (field.mimeTypes) f.mimeTypes = [...field.mimeTypes];
|
|
4499
4778
|
break;
|
|
4500
4779
|
case "array":
|
|
4501
4780
|
if (field.minRows !== void 0) f.minRows = field.minRows;
|
|
@@ -4573,12 +4852,13 @@ async function global(config, user, localized) {
|
|
|
4573
4852
|
if (config.admin?.sidebar?.length) result.sidebar = config.admin.sidebar.map(componentRef);
|
|
4574
4853
|
return result;
|
|
4575
4854
|
}
|
|
4576
|
-
async function adminSchema(cms, user) {
|
|
4855
|
+
async function adminSchema(cms, user, origin) {
|
|
4577
4856
|
const collections = cms.config.collections.filter((c) => !INTERNAL_COLLECTIONS.has(c.slug));
|
|
4578
4857
|
const localization = cms.config.localization;
|
|
4579
4858
|
const localized = localization !== null;
|
|
4580
4859
|
return {
|
|
4581
4860
|
locale: cms.config.admin.locale,
|
|
4861
|
+
passwordLinks: cms.auth.canSendPasswordLinks(origin),
|
|
4582
4862
|
menu: [...cms.config.admin.menu],
|
|
4583
4863
|
localization: localization ? { locales: [...localization.locales], defaultLocale: localization.defaultLocale } : null,
|
|
4584
4864
|
collections: await Promise.all(collections.map((c) => collection(c, user, localized))),
|
|
@@ -4888,7 +5168,46 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
4888
5168
|
return { body: { user: ctx.user, ...csrfToken2 ? { csrfToken: csrfToken2 } : {} } };
|
|
4889
5169
|
}
|
|
4890
5170
|
case "GET init":
|
|
4891
|
-
return {
|
|
5171
|
+
return {
|
|
5172
|
+
body: {
|
|
5173
|
+
hasUsers: await cms.auth.hasUsers(),
|
|
5174
|
+
// The login page offers "Forgot password?" only when the email can be sent.
|
|
5175
|
+
passwordReset: cms.auth.canSendPasswordLinks(ctx.url.origin)
|
|
5176
|
+
}
|
|
5177
|
+
};
|
|
5178
|
+
// Forgot password: the same answer whether or not the email has an account.
|
|
5179
|
+
case "POST forgot-password": {
|
|
5180
|
+
const body = await readJson(ctx.request);
|
|
5181
|
+
await cms.auth.requestPasswordReset({
|
|
5182
|
+
email: String(body.email ?? ""),
|
|
5183
|
+
ip: options.getClientIp?.(ctx.request),
|
|
5184
|
+
origin: ctx.url.origin,
|
|
5185
|
+
locale: typeof body.locale === "string" ? body.locale : void 0
|
|
5186
|
+
});
|
|
5187
|
+
return { body: { message: "If an account has this email, a link is on its way." } };
|
|
5188
|
+
}
|
|
5189
|
+
// The page behind a password link: which account, and whether the link still works.
|
|
5190
|
+
case "GET reset-password": {
|
|
5191
|
+
const found = await cms.auth.checkPasswordToken(ctx.url.searchParams.get("token") ?? "");
|
|
5192
|
+
if (!found)
|
|
5193
|
+
throw new ValidationError(USERS, [
|
|
5194
|
+
{ field: "token", message: "This link has expired or was already used" }
|
|
5195
|
+
]);
|
|
5196
|
+
return { body: { email: found.user.email, purpose: found.purpose } };
|
|
5197
|
+
}
|
|
5198
|
+
case "POST reset-password": {
|
|
5199
|
+
const body = await readJson(ctx.request);
|
|
5200
|
+
const session = await cms.auth.resetPassword({
|
|
5201
|
+
token: String(body.token ?? ""),
|
|
5202
|
+
password: String(body.password ?? ""),
|
|
5203
|
+
ip: options.getClientIp?.(ctx.request),
|
|
5204
|
+
locale: typeof body.locale === "string" ? body.locale : void 0
|
|
5205
|
+
});
|
|
5206
|
+
setSessionCookies(cms, ctx, session);
|
|
5207
|
+
return {
|
|
5208
|
+
body: { user: session.user, exp: session.expiresAt, csrfToken: session.csrfToken }
|
|
5209
|
+
};
|
|
5210
|
+
}
|
|
4892
5211
|
case "POST first-register": {
|
|
4893
5212
|
const body = await readJson(ctx.request);
|
|
4894
5213
|
const session = await cms.auth.registerFirstUser({
|
|
@@ -4919,7 +5238,7 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
4919
5238
|
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
4920
5239
|
if (!ctx.user) throw new UnauthorizedError();
|
|
4921
5240
|
if (second === "schema" && third === void 0)
|
|
4922
|
-
return { body: await adminSchema(cms, ctx.user) };
|
|
5241
|
+
return { body: await adminSchema(cms, ctx.user, ctx.url.origin) };
|
|
4923
5242
|
if (second === "modules" && third !== void 0 && segments.length === 3) {
|
|
4924
5243
|
const index = /^(\d+)\.js$/.exec(third)?.[1];
|
|
4925
5244
|
const file = index === void 0 ? void 0 : await readAdminModule(cms, Number(index));
|
|
@@ -5055,6 +5374,16 @@ async function documentAction(cms, ctx, method, collection2, id, path) {
|
|
|
5055
5374
|
const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
|
|
5056
5375
|
const depth = parseDepth(ctx.url);
|
|
5057
5376
|
const [action, versionId, extra] = path;
|
|
5377
|
+
if (collection2 === USERS && action === "password-link" && path.length === 1) {
|
|
5378
|
+
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
5379
|
+
if (ctx.user?.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
|
|
5380
|
+
const body = await readJson(ctx.request).catch(() => ({}));
|
|
5381
|
+
const sent = await cms.auth.sendPasswordLink(/^\d+$/.test(id) ? Number(id) : id, {
|
|
5382
|
+
origin: ctx.url.origin,
|
|
5383
|
+
locale: typeof body.locale === "string" ? body.locale : void 0
|
|
5384
|
+
});
|
|
5385
|
+
return { body: { sent } };
|
|
5386
|
+
}
|
|
5058
5387
|
if (action === "versions" && versionId === void 0) {
|
|
5059
5388
|
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
5060
5389
|
return { body: await cms.findVersions(collection2, id, { ...access, ...parsePage(ctx.url) }) };
|
|
@@ -5370,8 +5699,10 @@ function fieldType(field, n, indent) {
|
|
|
5370
5699
|
const union = field.options.map((o) => quote(typeof o === "string" ? o : o.value)).join(" | ") || "string";
|
|
5371
5700
|
return field.hasMany ? `(${union})[]` : union;
|
|
5372
5701
|
}
|
|
5373
|
-
case "upload":
|
|
5374
|
-
|
|
5702
|
+
case "upload": {
|
|
5703
|
+
const one = `ID | ${n.collections.get(MEDIA) ?? "Record<string, unknown>"}`;
|
|
5704
|
+
return field.hasMany ? `(${one})[]` : one;
|
|
5705
|
+
}
|
|
5375
5706
|
case "relationship": {
|
|
5376
5707
|
const target = n.collections.get(field.to);
|
|
5377
5708
|
const one = target ? `ID | ${target}` : "ID";
|
|
@@ -5403,7 +5734,7 @@ function fieldLines(fields, n, indent) {
|
|
|
5403
5734
|
for (const field of fields) {
|
|
5404
5735
|
if (field.hidden) continue;
|
|
5405
5736
|
const type = fieldType(field, n, indent);
|
|
5406
|
-
const alwaysPresent = field.type === "array" || field.type === "blocks" || field.type === "group" || (field
|
|
5737
|
+
const alwaysPresent = field.type === "array" || field.type === "blocks" || field.type === "group" || isHasMany(field);
|
|
5407
5738
|
if (field.required || alwaysPresent) lines.push(`${indent}${key(field.name)}: ${type}`);
|
|
5408
5739
|
else lines.push(`${indent}${key(field.name)}?: ${type} | null`);
|
|
5409
5740
|
}
|
|
@@ -5496,6 +5827,7 @@ export {
|
|
|
5496
5827
|
DEFAULT_DEPTH,
|
|
5497
5828
|
DEFAULT_MAX_FILE_SIZE,
|
|
5498
5829
|
DEFAULT_MAX_VERSIONS,
|
|
5830
|
+
DEFAULT_PASSWORD_EMAILS,
|
|
5499
5831
|
DEFAULT_TOKEN_EXPIRATION,
|
|
5500
5832
|
EXTENSIONS,
|
|
5501
5833
|
EasyCMS,
|
|
@@ -5536,12 +5868,14 @@ export {
|
|
|
5536
5868
|
imageDimensions,
|
|
5537
5869
|
importConfig,
|
|
5538
5870
|
isAdmin,
|
|
5871
|
+
isHasMany,
|
|
5539
5872
|
isLoggedIn,
|
|
5540
5873
|
keyAllows,
|
|
5541
5874
|
loadConfig,
|
|
5542
5875
|
localStorage,
|
|
5543
5876
|
mergeForUpdate,
|
|
5544
5877
|
mimeAllowed,
|
|
5878
|
+
mimeAllowedBy,
|
|
5545
5879
|
parseId,
|
|
5546
5880
|
populate,
|
|
5547
5881
|
readCookie,
|