@easy-cms/core 0.47.4 → 0.48.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 CHANGED
@@ -137,7 +137,7 @@ interface UserIdentity {
137
137
  lastUsedAt: string | null;
138
138
  }
139
139
  /** Why a sign-in did not work, as `?sso=` on the page it ends on. */
140
- type SsoOutcome = 'linked' | 'cancelled' | 'expired' | 'failed' | 'no-account' | 'unverified' | 'inactive' | 'taken';
140
+ type SsoOutcome = 'linked' | 'cancelled' | 'expired' | 'failed' | 'no-account' | 'link-first' | 'unverified' | 'inactive' | 'taken';
141
141
  /** Single sign-on with `auth.providers`. Available as `cms.auth.sso`. */
142
142
  declare class SingleSignOn {
143
143
  private readonly cms;
@@ -195,6 +195,8 @@ declare class SingleSignOn {
195
195
  private userByEmail;
196
196
  /** A new account for people from `allowSignUp.domains`. */
197
197
  private signUp;
198
+ /** A site member (`auth.members`): matched by email, like any shop or site account. */
199
+ private isMember;
198
200
  private defaultRole;
199
201
  }
200
202
 
@@ -287,6 +289,12 @@ declare class Auth {
287
289
  */
288
290
  private memberLink;
289
291
  hasUsers(): Promise<boolean>;
292
+ private warnedNoClientIp;
293
+ /**
294
+ * Without the client's address, failed logins count per email only, so anyone can lock an
295
+ * account for a while. Said once, in production.
296
+ */
297
+ private warnNoClientIp;
290
298
  /** Ends the session for a signed token. Unknown tokens are ignored. */
291
299
  logout(signedToken: string): Promise<void>;
292
300
  /**
@@ -1225,6 +1233,8 @@ declare class MediaFolders {
1225
1233
  interface StoredFile {
1226
1234
  readonly body: Uint8Array;
1227
1235
  readonly size: number;
1236
+ /** The type the storage serves the file with, when it says (checked after direct uploads). */
1237
+ readonly contentType?: string;
1228
1238
  }
1229
1239
  /** Where uploaded files live. Keys are generated by Easy CMS: `<name>-<random>.<ext>`. */
1230
1240
  interface StorageAdapter {
@@ -1633,6 +1643,11 @@ declare class EasyCMS<C extends Config = Config> {
1633
1643
  /** The global's pending draft when it has one (see `withDrafts`), otherwise `saved`. */
1634
1644
  private globalDraft;
1635
1645
  private saveGlobal;
1646
+ /**
1647
+ * Users changing their own password or email say their current password first, so a session
1648
+ * someone else got hold of can't lock them out. Admins changing other users don't.
1649
+ */
1650
+ private checkOwnCredentials;
1636
1651
  /**
1637
1652
  * What `user` may do with one document. Resolves `where`-style access
1638
1653
  * against the document, so the admin UI can hide actions precisely.
@@ -2066,7 +2081,10 @@ interface BlocksField extends BaseField<'blocks', never> {
2066
2081
  declare const uniqueWithinOf: (field: Field) => readonly string[];
2067
2082
  /** Fields with `hasMany`: their value is a list of options, documents or files. */
2068
2083
  declare const isHasMany: (field: Field) => boolean;
2069
- /** Whether a MIME type is one of `patterns` (`image/*` matches every image). */
2084
+ /**
2085
+ * Whether a MIME type is one of `patterns` (`image/*` matches every image but SVG, which can
2086
+ * carry scripts and is allowed only when listed by name: `image/svg+xml`).
2087
+ */
2070
2088
  declare function mimeAllowedBy(mimeType: string, patterns: readonly string[]): boolean;
2071
2089
  /** Fields whose value is a list of rows: `array` and `blocks`. */
2072
2090
  declare const hasRows: (field: Field) => field is ArrayField | BlocksField;
@@ -2286,6 +2304,12 @@ interface AuthProvider {
2286
2304
  }): Promise<AuthProviderProfile>;
2287
2305
  /** For Settings → SSO: what it connects to, never secrets. */
2288
2306
  describe?(): Readonly<Record<string, string>>;
2307
+ /**
2308
+ * Signs staff who have an account in by their verified email the first time, without linking
2309
+ * the provider from their account page. Only for a provider whose emails your organization
2310
+ * controls (e.g. its Google Workspace). Default `false`; site members are always matched.
2311
+ */
2312
+ readonly linkByEmail?: boolean;
2289
2313
  }
2290
2314
 
2291
2315
  interface ConfigIssue {
@@ -3375,9 +3399,20 @@ interface ApiHandlerOptions {
3375
3399
  * Takes precedence over `trustProxy`.
3376
3400
  */
3377
3401
  readonly getClientIp?: (request: Request) => string | undefined;
3378
- /** Use the first `X-Forwarded-For` address. Only behind a proxy you trust (Vercel, Netlify…). */
3402
+ /**
3403
+ * Behind a proxy that adds the client's address to `X-Forwarded-For`: the last address in it
3404
+ * (the one the proxy added; earlier ones come from the client and can say anything). Vercel
3405
+ * and Netlify are recognized without it.
3406
+ */
3379
3407
  readonly trustProxy?: boolean;
3380
3408
  }
3409
+ /**
3410
+ * The client's address from `X-Forwarded-For` as the nearest proxy wrote it: its last entry.
3411
+ * Earlier entries were sent by the client, so they can't be trusted.
3412
+ */
3413
+ declare function forwardedClientIp(value: string | null | undefined): string | undefined;
3414
+ /** The client's address on hosting platforms that set it themselves: Vercel, Netlify. */
3415
+ declare function platformClientIp(headers: Headers): string | undefined;
3381
3416
  /**
3382
3417
  * The REST API as a `(Request) => Response` handler for a framework's route, on the shared
3383
3418
  * instance (`sharedEasyCMS`). Mount it at `routes.api` (default `/api/cms`), for every method.
@@ -3416,7 +3451,10 @@ declare function expandMimeTypes(patterns: readonly string[]): string[];
3416
3451
  * tells CSV from plain text, which have the same contents.
3417
3452
  */
3418
3453
  declare function sniffMimeType(data: Uint8Array, name?: string): string | undefined;
3419
- /** `image/*` style matching; group names (`documents`…) stand for their types. */
3454
+ /**
3455
+ * `image/*` style matching; group names (`documents`…) stand for their types. A wildcard leaves
3456
+ * out SVG, which can carry scripts: it is allowed only when listed as `image/svg+xml`.
3457
+ */
3420
3458
  declare function mimeAllowed(type: string, allowed: readonly string[]): boolean;
3421
3459
  /** Width and height of PNG, GIF, JPEG and WebP images without decoding them. */
3422
3460
  declare function imageDimensions(data: Uint8Array, type: string): {
@@ -3927,4 +3965,4 @@ declare function validateConfig(config: Config): ConfigIssue[];
3927
3965
  /** The version of `@easy-cms/core`, set when the package is built. */
3928
3966
  declare const VERSION: string;
3929
3967
 
3930
- export { ADMIN_ICONS, API_KEYS, API_KEY_GLOBAL_OPERATIONS, API_KEY_OPERATIONS, type Access, type AccessArgs, type AccessOptions, type AdminAttention, type AdminBackup, type AdminBackups, type AdminBrand, type AdminCollection, type AdminCommand, type AdminComponent, type AdminComponentRef, type AdminConfig, type AdminDeliveries, type AdminDelivery, type AdminEmail, type AdminEmailDelivery, type AdminField, type AdminFieldArgs, type AdminGlobal, type AdminIcon, type AdminLayoutNode, type AdminLocale, type AdminNavGroup, type AdminNavItem, type AdminNavNode, type AdminPage, type AdminPageRef, type AdminRole, type AdminRoleChange, type AdminRoleField, type AdminRoleTarget, type AdminRoleView, type AdminRoles, type AdminSchema, type AdminSso, type AdminStatus, type AdminSwitcher, type AdminViewAccess, type AdminWebhookDelivery, type AdminWidgetRef, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ApiHandlerOptions, type ApiKeyContext, type ApiKeyGlobalOperation, type ApiKeyOperation, type ApiKeyPermissions, type ArrayField, type AuditChange, type AuditConfig, type AuditEntry, AuditLog, type AuditPage, type AuditVerification, Auth, type AuthConfig, type AuthProvider, type AuthProviderProfile, type AuthProviderRequest, type AuthUser, BUILTIN_COLLECTIONS, BUILTIN_NAV, type BackupState, type BackupsConfig, type BeforeChangeHook, type BeforeDeleteHook, type BeforeValidateHook, type Block, type BlocksField, type BooleanField, type BuiltinApiKeysCollection, type BuiltinMediaCollection, type BuiltinMediaFoldersCollection, 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, type CustomField, type CustomFieldTypes, DEFAULT_ADMIN_PATH, DEFAULT_API_PATH, DEFAULT_DEPTH, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_VERSIONS, DEFAULT_PASSWORD_EMAILS, DEFAULT_TOKEN_EXPIRATION, type DashboardWidget, type Database, type DatabaseAdapter, type DatabaseInitArgs, type DatabaseTransfer, type DateField, type DeliveryKind, type DeliveryState, type DepthOptions, type DirectUpload, type DocumentOf, EXTENSIONS, EasyCMS, EasyCMSError, type EmailAdapter, type EmailCheck, type EmailField, type EmailMessage, type EmailSetting, type Endpoint, type EndpointRequest, FIELD_TYPES, FIELD_TYPE_BASES, FOLDER_LEVELS, type Field, type FieldAccess, type FieldAccessFn, type FieldAdmin, FieldCondition, type FieldError, type FieldRule, type FieldType, type FieldTypeBase, type FieldTypeDefinition, type FieldTypeValidateContext, type FieldValidate, type FieldValidateContext, type FieldValue, type FieldsInput, type FieldsValue, type FilterOptions, type FilterOptionsArgs, type FindArgs, type FindOptions, type FolderLevel, type FolderPermissions, ForbiddenError, type GlobalAccess, type GlobalConfig, type GlobalDocument, type GlobalHooks, type GlobalInput, type GlobalSlug, type GroupField, type ID, INTERNAL_COLLECTIONS, type ImageSize, type IncrementOptions, type InferCollection, type InferGlobal, type JobConfig, type JsonField, type Label, type LayoutNode, type LivePreview, type LoadConfigOptions, type LocalStorageOptions, type LocalizationConfig, type Logger, type LoginArgs, MAX_DEPTH, MEDIA, MEDIA_FOLDERS, MIME_GROUPS, MIN_PASSWORD_LENGTH, MIN_SECRET_LENGTH, type MediaDocument, MediaFolders, type MediaSize, type MembersConfig, type MembersSignup, type MigrationInfo, type NavGroup, NotFoundError, type NumberField, type OnRequest, type OnRequestArgs, type Operation, type PaginatedDocs, type PasswordEmail, type PasswordEmailArgs, type PasswordEmailFn, type PasswordLinkOptions, PayloadTooLargeError, type Plugin, type PluginInfo, type PluginTypes, type PreviewTarget, type PreviewURL, QueryError, ROLE_OPERATIONS, type RawDocument, type ReadOptions, type RelationshipField, type RequestContext, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoleOperation, type RolePermissions, Roles, type RoutesConfig, SESSION_COOKIE, SSO_COOKIE, type ScheduledJob, SchemaError, type SchemaMode, type SearchHit, type SelectField, type SelectOption, type Session, type SidebarPanel, type SignupArgs, type SignupResult, type SlugField, type SlugOf, type SsoOutcome, type SsoProviderRef, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, type TypedPlugin, UnauthorizedError, type UpdateInput, type UpdateOptions, type UploadConfig, type UploadField, type UploadFromURLConfig, type UserIdentity, VERSION, ValidationError, type Version, type VersionSummary, type VersionsConfig, WEBHOOK_EVENTS, type WebhookConfig, type WebhookEvent, type WebhookPayload, type Where, type WhereOperators, anyone, applyDefaults, applyPlugins, checkFormToken, configSignature, consoleEmail, consoleLogger, copyDatabase, createApiHandler, createEasyCMS, createRestHandler, createRootEndpointHandler, defineConfig, defineFieldType, definePlugin, expandMimeTypes, fillMissing, findConfigFile, formToken, generateSlugs, generateTypes, hasRows, hashPassword, honeypotName, imageDimensions, importConfig, isAdmin, isHasMany, isLoggedIn, isSignedIn, isSystemAdmin, keyAllows, loadConfig, localStorage, mergeForUpdate, mimeAllowed, mimeAllowedBy, parseId, populate, rateKeys, readCookie, resolveAdminModule, resolveConfig, rowFields, sharedEasyCMS, silentLogger, singularize, slugify, sniffMimeType, uniqueWithinOf, validateConfig, validateFields, verifyPassword, verifyTurnstile, writeBackupFile };
3968
+ export { ADMIN_ICONS, API_KEYS, API_KEY_GLOBAL_OPERATIONS, API_KEY_OPERATIONS, type Access, type AccessArgs, type AccessOptions, type AdminAttention, type AdminBackup, type AdminBackups, type AdminBrand, type AdminCollection, type AdminCommand, type AdminComponent, type AdminComponentRef, type AdminConfig, type AdminDeliveries, type AdminDelivery, type AdminEmail, type AdminEmailDelivery, type AdminField, type AdminFieldArgs, type AdminGlobal, type AdminIcon, type AdminLayoutNode, type AdminLocale, type AdminNavGroup, type AdminNavItem, type AdminNavNode, type AdminPage, type AdminPageRef, type AdminRole, type AdminRoleChange, type AdminRoleField, type AdminRoleTarget, type AdminRoleView, type AdminRoles, type AdminSchema, type AdminSso, type AdminStatus, type AdminSwitcher, type AdminViewAccess, type AdminWebhookDelivery, type AdminWidgetRef, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ApiHandlerOptions, type ApiKeyContext, type ApiKeyGlobalOperation, type ApiKeyOperation, type ApiKeyPermissions, type ArrayField, type AuditChange, type AuditConfig, type AuditEntry, AuditLog, type AuditPage, type AuditVerification, Auth, type AuthConfig, type AuthProvider, type AuthProviderProfile, type AuthProviderRequest, type AuthUser, BUILTIN_COLLECTIONS, BUILTIN_NAV, type BackupState, type BackupsConfig, type BeforeChangeHook, type BeforeDeleteHook, type BeforeValidateHook, type Block, type BlocksField, type BooleanField, type BuiltinApiKeysCollection, type BuiltinMediaCollection, type BuiltinMediaFoldersCollection, 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, type CustomField, type CustomFieldTypes, DEFAULT_ADMIN_PATH, DEFAULT_API_PATH, DEFAULT_DEPTH, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_VERSIONS, DEFAULT_PASSWORD_EMAILS, DEFAULT_TOKEN_EXPIRATION, type DashboardWidget, type Database, type DatabaseAdapter, type DatabaseInitArgs, type DatabaseTransfer, type DateField, type DeliveryKind, type DeliveryState, type DepthOptions, type DirectUpload, type DocumentOf, EXTENSIONS, EasyCMS, EasyCMSError, type EmailAdapter, type EmailCheck, type EmailField, type EmailMessage, type EmailSetting, type Endpoint, type EndpointRequest, FIELD_TYPES, FIELD_TYPE_BASES, FOLDER_LEVELS, type Field, type FieldAccess, type FieldAccessFn, type FieldAdmin, FieldCondition, type FieldError, type FieldRule, type FieldType, type FieldTypeBase, type FieldTypeDefinition, type FieldTypeValidateContext, type FieldValidate, type FieldValidateContext, type FieldValue, type FieldsInput, type FieldsValue, type FilterOptions, type FilterOptionsArgs, type FindArgs, type FindOptions, type FolderLevel, type FolderPermissions, ForbiddenError, type GlobalAccess, type GlobalConfig, type GlobalDocument, type GlobalHooks, type GlobalInput, type GlobalSlug, type GroupField, type ID, INTERNAL_COLLECTIONS, type ImageSize, type IncrementOptions, type InferCollection, type InferGlobal, type JobConfig, type JsonField, type Label, type LayoutNode, type LivePreview, type LoadConfigOptions, type LocalStorageOptions, type LocalizationConfig, type Logger, type LoginArgs, MAX_DEPTH, MEDIA, MEDIA_FOLDERS, MIME_GROUPS, MIN_PASSWORD_LENGTH, MIN_SECRET_LENGTH, type MediaDocument, MediaFolders, type MediaSize, type MembersConfig, type MembersSignup, type MigrationInfo, type NavGroup, NotFoundError, type NumberField, type OnRequest, type OnRequestArgs, type Operation, type PaginatedDocs, type PasswordEmail, type PasswordEmailArgs, type PasswordEmailFn, type PasswordLinkOptions, PayloadTooLargeError, type Plugin, type PluginInfo, type PluginTypes, type PreviewTarget, type PreviewURL, QueryError, ROLE_OPERATIONS, type RawDocument, type ReadOptions, type RelationshipField, type RequestContext, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoleOperation, type RolePermissions, Roles, type RoutesConfig, SESSION_COOKIE, SSO_COOKIE, type ScheduledJob, SchemaError, type SchemaMode, type SearchHit, type SelectField, type SelectOption, type Session, type SidebarPanel, type SignupArgs, type SignupResult, type SlugField, type SlugOf, type SsoOutcome, type SsoProviderRef, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, type TypedPlugin, UnauthorizedError, type UpdateInput, type UpdateOptions, type UploadConfig, type UploadField, type UploadFromURLConfig, type UserIdentity, VERSION, ValidationError, type Version, type VersionSummary, type VersionsConfig, WEBHOOK_EVENTS, type WebhookConfig, type WebhookEvent, type WebhookPayload, type Where, type WhereOperators, anyone, applyDefaults, applyPlugins, checkFormToken, configSignature, consoleEmail, consoleLogger, copyDatabase, createApiHandler, createEasyCMS, createRestHandler, createRootEndpointHandler, defineConfig, defineFieldType, definePlugin, expandMimeTypes, fillMissing, findConfigFile, formToken, forwardedClientIp, generateSlugs, generateTypes, hasRows, hashPassword, honeypotName, imageDimensions, importConfig, isAdmin, isHasMany, isLoggedIn, isSignedIn, isSystemAdmin, keyAllows, loadConfig, localStorage, mergeForUpdate, mimeAllowed, mimeAllowedBy, parseId, platformClientIp, populate, rateKeys, readCookie, resolveAdminModule, resolveConfig, rowFields, sharedEasyCMS, silentLogger, singularize, slugify, sniffMimeType, uniqueWithinOf, validateConfig, validateFields, verifyPassword, verifyTurnstile, writeBackupFile };
package/dist/index.js CHANGED
@@ -1112,7 +1112,7 @@ function sniffMimeType(data, name = "") {
1112
1112
  }
1113
1113
  function mimeAllowed(type, allowed2) {
1114
1114
  return expandMimeTypes(allowed2).some(
1115
- (pattern) => pattern.endsWith("/*") ? type.startsWith(pattern.slice(0, -1)) : pattern === type
1115
+ (pattern) => pattern.endsWith("/*") ? type.startsWith(pattern.slice(0, -1)) && type !== "image/svg+xml" : pattern === type
1116
1116
  );
1117
1117
  }
1118
1118
  function imageDimensions(data, type) {
@@ -1190,9 +1190,10 @@ function mimeAllowedBy(mimeType, patterns) {
1190
1190
  const type = mimeType.toLowerCase();
1191
1191
  return expandMimeTypes(patterns).some((pattern) => {
1192
1192
  const p = pattern.toLowerCase();
1193
- return p.endsWith("/*") ? type.startsWith(p.slice(0, -1)) : type === p;
1193
+ return p.endsWith("/*") ? type.startsWith(p.slice(0, -1)) && type !== SVG : type === p;
1194
1194
  });
1195
1195
  }
1196
+ var SVG = "image/svg+xml";
1196
1197
  var hasRows = (field) => field.type === "array" || field.type === "blocks";
1197
1198
  function rowFields(field, row) {
1198
1199
  if (field.type === "array") return field.fields;
@@ -1690,6 +1691,8 @@ var SingleSignOn = class {
1690
1691
  if (!email2 || !profile.emailVerified)
1691
1692
  return await back("login", email2 ? "unverified" : "no-account");
1692
1693
  user = await this.userByEmail(email2);
1694
+ if (user && !this.isMember(user) && provider.linkByEmail !== true)
1695
+ return await back("login", "link-first");
1693
1696
  if (user && user.emailVerified === false) {
1694
1697
  const { id, ...rest } = user;
1695
1698
  user = await this.cms.db.update({
@@ -1842,6 +1845,10 @@ var SingleSignOn = class {
1842
1845
  this.cms.logger.info(`${email2} signed up with a provider`);
1843
1846
  return this.cms.db.findById({ collection: USERS, id: created.id });
1844
1847
  }
1848
+ /** A site member (`auth.members`): matched by email, like any shop or site account. */
1849
+ isMember(user) {
1850
+ return (this.config.auth.members?.roles ?? []).includes(String(user.role));
1851
+ }
1845
1852
  defaultRole() {
1846
1853
  const roles = this.config.auth.roles;
1847
1854
  return roles.includes("editor") ? "editor" : roles[roles.length - 1];
@@ -1880,6 +1887,7 @@ var Auth = class {
1880
1887
  const email2 = typeof args.email === "string" ? args.email.trim().toLowerCase() : "";
1881
1888
  const password = typeof args.password === "string" ? args.password : "";
1882
1889
  const key2 = `${email2}|${args.ip ?? ""}`;
1890
+ if (args.ip === void 0) this.warnNoClientIp();
1883
1891
  try {
1884
1892
  await this.checkRateLimit(key2);
1885
1893
  } catch (error) {
@@ -2103,6 +2111,18 @@ var Auth = class {
2103
2111
  async hasUsers() {
2104
2112
  return await this.db.count({ collection: USERS }) > 0;
2105
2113
  }
2114
+ warnedNoClientIp = false;
2115
+ /**
2116
+ * Without the client's address, failed logins count per email only, so anyone can lock an
2117
+ * account for a while. Said once, in production.
2118
+ */
2119
+ warnNoClientIp() {
2120
+ if (this.warnedNoClientIp || process.env.NODE_ENV !== "production") return;
2121
+ this.warnedNoClientIp = true;
2122
+ this.cms.logger.warn(
2123
+ "Easy CMS doesn't know the client's IP address, so logins are rate limited per email only. Behind a proxy that sets X-Forwarded-For, set `trustProxy: true` in the adapter."
2124
+ );
2125
+ }
2106
2126
  /** Ends the session for a signed token. Unknown tokens are ignored. */
2107
2127
  async logout(signedToken) {
2108
2128
  const token = unsignToken(this.config.secret, signedToken);
@@ -4409,6 +4429,7 @@ var Roles = class {
4409
4429
  async canView(user, id) {
4410
4430
  if (isSystemAdmin(user)) return true;
4411
4431
  if (user.apiKey) return false;
4432
+ if (id === "deliveries" && user.scoped === true) return false;
4412
4433
  if (id === "audit" && user.role === "admin" && user.scoped === true && this.config.audit !== false && typeof this.config.audit.scope === "function")
4413
4434
  return true;
4414
4435
  if (!this.enabled) return id.startsWith("page:") || id.startsWith("widget:");
@@ -5248,8 +5269,13 @@ function validateSecret(secret, add) {
5248
5269
  );
5249
5270
  } else if (secret.length < MIN_SECRET_LENGTH) {
5250
5271
  add("secret", `must be at least ${MIN_SECRET_LENGTH} characters (got ${secret.length})`, hint);
5272
+ } else if (process.env.NODE_ENV === "production" && guessable(secret)) {
5273
+ add("secret", "looks like an example or a repeated pattern, not a random string", hint);
5251
5274
  }
5252
5275
  }
5276
+ function guessable(secret) {
5277
+ return /change[-_ ]?me|example|placeholder|secret-?key/i.test(secret) || new Set(secret).size < 8;
5278
+ }
5253
5279
  function validateAdmin(config, add) {
5254
5280
  const admin = config.admin;
5255
5281
  if (admin === void 0) return;
@@ -6830,6 +6856,8 @@ var EasyCMS = class {
6830
6856
  const mimeType = sniffMimeType(start.body.subarray(0, SNIFF_BYTES), t.name);
6831
6857
  if (mimeType !== t.type)
6832
6858
  await refuse(`contains ${mimeType ?? "an unknown type"}, not ${t.type}`);
6859
+ const served = start.contentType?.split(";")[0]?.trim().toLowerCase();
6860
+ if (served && served !== t.type) await refuse(`is stored as ${served}, not ${t.type}`);
6833
6861
  const stored = [];
6834
6862
  try {
6835
6863
  let dimensions;
@@ -7381,6 +7409,7 @@ var EasyCMS = class {
7381
7409
  raw = rest;
7382
7410
  }
7383
7411
  const { input, password } = splitPassword(config, raw);
7412
+ if (config.slug === USERS) await this.checkOwnCredentials(guard, existing, raw, password);
7384
7413
  const filtered = await filterInput(
7385
7414
  config.fields,
7386
7415
  input,
@@ -7700,6 +7729,33 @@ var EasyCMS = class {
7700
7729
  await this.audited(config, "update", doc, current, guard, mode);
7701
7730
  return this.readGlobal(config, key2, guard, { ...options, draft: true });
7702
7731
  }
7732
+ /**
7733
+ * Users changing their own password or email say their current password first, so a session
7734
+ * someone else got hold of can't lock them out. Admins changing other users don't.
7735
+ */
7736
+ async checkOwnCredentials(guard, existing, raw, password) {
7737
+ const user = guard.user;
7738
+ if (!guard.enforce || !user || user.apiKey || String(user.id) !== String(existing.id)) return;
7739
+ const email2 = typeof raw.email === "string" ? raw.email.trim().toLowerCase() : void 0;
7740
+ const emailChanges = email2 !== void 0 && email2 !== String(existing.email ?? "").toLowerCase();
7741
+ if (password === void 0 && !emailChanges) return;
7742
+ const hash = typeof existing.passwordHash === "string" ? existing.passwordHash : void 0;
7743
+ if (!hash) {
7744
+ if (emailChanges)
7745
+ throw new ValidationError(USERS, [
7746
+ { field: "email", message: "can't be changed on an account without a password" }
7747
+ ]);
7748
+ return;
7749
+ }
7750
+ const current = typeof raw.currentPassword === "string" ? raw.currentPassword : "";
7751
+ if (!current || !await verifyPassword(current, hash))
7752
+ throw new ValidationError(USERS, [
7753
+ {
7754
+ field: "currentPassword",
7755
+ message: current ? "is not right" : "is required to change your password or email"
7756
+ }
7757
+ ]);
7758
+ }
7703
7759
  /**
7704
7760
  * What `user` may do with one document. Resolves `where`-style access
7705
7761
  * against the document, so the admin UI can hide actions precisely.
@@ -8817,8 +8873,10 @@ function deny(user) {
8817
8873
  return user ? new ForbiddenError() : new UnauthorizedError();
8818
8874
  }
8819
8875
  function splitPassword(config, raw) {
8820
- if (config.slug !== USERS || !Object.hasOwn(raw, "password")) return { input: raw };
8821
- const { password, ...input } = raw;
8876
+ if (config.slug !== USERS) return { input: raw };
8877
+ const { currentPassword: _current, ...rest } = raw;
8878
+ if (!Object.hasOwn(rest, "password")) return { input: rest };
8879
+ const { password, ...input } = rest;
8822
8880
  if (password === void 0 || password === null || password === "") return { input };
8823
8881
  if (typeof password !== "string" || password.length < MIN_PASSWORD_LENGTH) {
8824
8882
  throw new ValidationError(USERS, [
@@ -9569,7 +9627,7 @@ async function adminSearch(cms, who, q, perCollection = 5) {
9569
9627
  }
9570
9628
 
9571
9629
  // src/version.ts
9572
- var VERSION = true ? "0.47.4" : "0.0.0-dev";
9630
+ var VERSION = true ? "0.48.0" : "0.0.0-dev";
9573
9631
 
9574
9632
  // src/rest/admin-status.ts
9575
9633
  var DAY2 = 864e5;
@@ -10939,9 +10997,19 @@ function sharedEasyCMS(config, options) {
10939
10997
  });
10940
10998
  return promise;
10941
10999
  }
11000
+ function forwardedClientIp(value) {
11001
+ const last = value?.split(",").map((part) => part.trim()).filter(Boolean).at(-1);
11002
+ return last || void 0;
11003
+ }
11004
+ function platformClientIp(headers) {
11005
+ if (process.env.VERCEL)
11006
+ return headers.get("x-real-ip") ?? forwardedClientIp(headers.get("x-forwarded-for"));
11007
+ if (process.env.NETLIFY) return headers.get("x-nf-client-connection-ip") ?? void 0;
11008
+ return void 0;
11009
+ }
10942
11010
  function createApiHandler(config, options = {}) {
10943
11011
  const handlers = /* @__PURE__ */ new WeakMap();
10944
- const getClientIp = options.getClientIp ?? (options.trustProxy ? (r) => r.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || void 0 : () => void 0);
11012
+ const getClientIp = options.getClientIp ?? (options.trustProxy ? (r) => forwardedClientIp(r.headers.get("x-forwarded-for")) : (r) => platformClientIp(r.headers));
10945
11013
  return async (request) => {
10946
11014
  const cms = await sharedEasyCMS(config);
10947
11015
  let handler = handlers.get(cms);
@@ -11237,6 +11305,7 @@ export {
11237
11305
  fillMissing,
11238
11306
  findConfigFile,
11239
11307
  formToken,
11308
+ forwardedClientIp,
11240
11309
  generateSlugs,
11241
11310
  generateTypes,
11242
11311
  hasRows,
@@ -11257,6 +11326,7 @@ export {
11257
11326
  mimeAllowed,
11258
11327
  mimeAllowedBy,
11259
11328
  parseId,
11329
+ platformClientIp,
11260
11330
  populate,
11261
11331
  rateKeys,
11262
11332
  readCookie,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "0.47.4",
3
+ "version": "0.48.0",
4
4
  "description": "Embedded, code-first headless CMS for Nuxt and Next.js",
5
5
  "license": "MIT",
6
6
  "type": "module",