@easy-cms/core 0.47.3 → 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 {
@@ -2957,6 +2981,8 @@ interface EndpointRequest {
2957
2981
  readonly cms: EasyCMS;
2958
2982
  /** The request's JSON body, which must be an object (at most 1 MB). */
2959
2983
  json(): Promise<Record<string, unknown>>;
2984
+ /** The request's body as text, as sent (at most 1 MB), e.g. to check a webhook's signature. */
2985
+ text(): Promise<string>;
2960
2986
  }
2961
2987
  /**
2962
2988
  * Work that runs with the scheduled jobs: every minute in a server, or each time a cron calls
@@ -3373,9 +3399,20 @@ interface ApiHandlerOptions {
3373
3399
  * Takes precedence over `trustProxy`.
3374
3400
  */
3375
3401
  readonly getClientIp?: (request: Request) => string | undefined;
3376
- /** 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
+ */
3377
3407
  readonly trustProxy?: boolean;
3378
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;
3379
3416
  /**
3380
3417
  * The REST API as a `(Request) => Response` handler for a framework's route, on the shared
3381
3418
  * instance (`sharedEasyCMS`). Mount it at `routes.api` (default `/api/cms`), for every method.
@@ -3414,7 +3451,10 @@ declare function expandMimeTypes(patterns: readonly string[]): string[];
3414
3451
  * tells CSV from plain text, which have the same contents.
3415
3452
  */
3416
3453
  declare function sniffMimeType(data: Uint8Array, name?: string): string | undefined;
3417
- /** `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
+ */
3418
3458
  declare function mimeAllowed(type: string, allowed: readonly string[]): boolean;
3419
3459
  /** Width and height of PNG, GIF, JPEG and WebP images without decoding them. */
3420
3460
  declare function imageDimensions(data: Uint8Array, type: string): {
@@ -3925,4 +3965,4 @@ declare function validateConfig(config: Config): ConfigIssue[];
3925
3965
  /** The version of `@easy-cms/core`, set when the package is built. */
3926
3966
  declare const VERSION: string;
3927
3967
 
3928
- 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,16 @@ 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");
1696
+ if (user && user.emailVerified === false) {
1697
+ const { id, ...rest } = user;
1698
+ user = await this.cms.db.update({
1699
+ collection: USERS,
1700
+ id,
1701
+ data: { ...rest, emailVerified: true, passwordHash: null }
1702
+ });
1703
+ }
1693
1704
  if (!user) user = await this.signUp(email2, profile.name);
1694
1705
  if (!user) return await back("login", "no-account");
1695
1706
  await this.addIdentity(user.id, provider.id, subject, email2);
@@ -1742,7 +1753,7 @@ var SingleSignOn = class {
1742
1753
  const row = await this.cms.db.findById({ collection: USER_IDENTITIES, id: identityId });
1743
1754
  if (!row) throw new NotFoundError(USER_IDENTITIES, identityId);
1744
1755
  const own = String(row.user) === String(actor.id);
1745
- if (!own && (actor.role !== "admin" || actor.apiKey)) throw new ForbiddenError();
1756
+ if (!own && (!isSystemAdmin(actor) || actor.apiKey)) throw new ForbiddenError();
1746
1757
  const user = await this.cms.db.findById({ collection: USERS, id: row.user });
1747
1758
  const others = (await this.identities(row.user)).length - 1;
1748
1759
  const password = !!user?.passwordHash && this.passwordAllowed(user);
@@ -1834,6 +1845,10 @@ var SingleSignOn = class {
1834
1845
  this.cms.logger.info(`${email2} signed up with a provider`);
1835
1846
  return this.cms.db.findById({ collection: USERS, id: created.id });
1836
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
+ }
1837
1852
  defaultRole() {
1838
1853
  const roles = this.config.auth.roles;
1839
1854
  return roles.includes("editor") ? "editor" : roles[roles.length - 1];
@@ -1872,6 +1887,7 @@ var Auth = class {
1872
1887
  const email2 = typeof args.email === "string" ? args.email.trim().toLowerCase() : "";
1873
1888
  const password = typeof args.password === "string" ? args.password : "";
1874
1889
  const key2 = `${email2}|${args.ip ?? ""}`;
1890
+ if (args.ip === void 0) this.warnNoClientIp();
1875
1891
  try {
1876
1892
  await this.checkRateLimit(key2);
1877
1893
  } catch (error) {
@@ -1994,8 +2010,16 @@ var Auth = class {
1994
2010
  throw new QueryError("Signing up needs `email` in the config, and a site URL in production");
1995
2011
  const existing = email2 ? await this.findByEmail(email2) : void 0;
1996
2012
  if (existing && verify) {
1997
- if (existing.emailVerified === false && existing.active !== false)
1998
- await this.mailVerifyLink(existing, args);
2013
+ if (existing.emailVerified === false && existing.active !== false) {
2014
+ await this.cms.update(USERS, existing.id, { password: args.password }, {
2015
+ context: args.context ?? {}
2016
+ });
2017
+ const pending = await this.db.findById({
2018
+ collection: USERS,
2019
+ id: existing.id
2020
+ });
2021
+ await this.mailVerifyLink(pending, args);
2022
+ }
1999
2023
  return { verify: true };
2000
2024
  }
2001
2025
  const created = await this.cms.create(
@@ -2030,14 +2054,15 @@ var Auth = class {
2030
2054
  const claims = readPasswordToken(token);
2031
2055
  const id = claims && (/^\d+$/.test(claims.userId) ? Number(claims.userId) : claims.userId);
2032
2056
  const user = claims && claims.purpose === "verify" && claims.expiresAt > Date.now() && id !== null ? await this.db.findById({ collection: USERS, id }) : null;
2033
- if (!user || user.active === false || !passwordTokenMatches(this.config.secret, token, passwordFingerprint(user.passwordHash))) {
2057
+ if (!user || user.active === false || // A link confirms once: it doesn't sign in again afterwards.
2058
+ user.emailVerified !== false || !passwordTokenMatches(this.config.secret, token, passwordFingerprint(user.passwordHash))) {
2034
2059
  await this.recordFailure(key2);
2035
2060
  throw new ValidationError(USERS, [
2036
2061
  { field: "token", message: "This link has expired or is not valid" }
2037
2062
  ]);
2038
2063
  }
2039
2064
  await this.clearFailures(key2);
2040
- if (user.emailVerified === false) {
2065
+ {
2041
2066
  const { id: _id, ...rest } = user;
2042
2067
  await this.db.update({
2043
2068
  collection: USERS,
@@ -2086,6 +2111,18 @@ var Auth = class {
2086
2111
  async hasUsers() {
2087
2112
  return await this.db.count({ collection: USERS }) > 0;
2088
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
+ }
2089
2126
  /** Ends the session for a signed token. Unknown tokens are ignored. */
2090
2127
  async logout(signedToken) {
2091
2128
  const token = unsignToken(this.config.secret, signedToken);
@@ -3988,12 +4025,41 @@ for (const [net, prefix] of [
3988
4025
  ["ff00::", 8]
3989
4026
  ])
3990
4027
  PRIVATE.addSubnet(net, prefix, "ipv6");
4028
+ var EMBEDS_IPV4 = new BlockList();
4029
+ EMBEDS_IPV4.addSubnet("64:ff9b::", 96, "ipv6");
4030
+ EMBEDS_IPV4.addSubnet("::", 96, "ipv6");
4031
+ function embeddedIPv4(address) {
4032
+ if (address === "::" || address === "::1" || !EMBEDS_IPV4.check(address, "ipv6")) return void 0;
4033
+ const bytes = ipv6Bytes(address);
4034
+ return bytes ? bytes.slice(12).join(".") : void 0;
4035
+ }
4036
+ function ipv6Bytes(address) {
4037
+ let text2 = address;
4038
+ const tail = /(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(text2);
4039
+ if (tail) {
4040
+ const [a, b, c, d] = tail.slice(1).map(Number);
4041
+ text2 = `${text2.slice(0, tail.index)}${(a << 8 | b).toString(16)}:${(c << 8 | d).toString(16)}`;
4042
+ }
4043
+ const [head = "", rest] = text2.split("::");
4044
+ const left = head ? head.split(":") : [];
4045
+ const right = rest === void 0 ? [] : rest ? rest.split(":") : [];
4046
+ const groups = rest === void 0 ? left : [...left, ...Array(8 - left.length - right.length).fill("0"), ...right];
4047
+ if (groups.length !== 8) return void 0;
4048
+ return groups.flatMap((g) => {
4049
+ const n = Number.parseInt(g, 16);
4050
+ return [n >> 8, n & 255];
4051
+ });
4052
+ }
3991
4053
  function isPrivateAddress(address) {
3992
4054
  const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address);
3993
4055
  if (mapped?.[1]) return PRIVATE.check(mapped[1], "ipv4");
3994
4056
  const family = isIP(address);
3995
4057
  if (family === 4) return PRIVATE.check(address, "ipv4");
3996
- if (family === 6) return PRIVATE.check(address, "ipv6");
4058
+ if (family === 6) {
4059
+ const embedded = embeddedIPv4(address);
4060
+ if (embedded) return PRIVATE.check(embedded, "ipv4");
4061
+ return PRIVATE.check(address, "ipv6");
4062
+ }
3997
4063
  return true;
3998
4064
  }
3999
4065
  function hostAllowed(host, allowedHosts) {
@@ -4363,6 +4429,7 @@ var Roles = class {
4363
4429
  async canView(user, id) {
4364
4430
  if (isSystemAdmin(user)) return true;
4365
4431
  if (user.apiKey) return false;
4432
+ if (id === "deliveries" && user.scoped === true) return false;
4366
4433
  if (id === "audit" && user.role === "admin" && user.scoped === true && this.config.audit !== false && typeof this.config.audit.scope === "function")
4367
4434
  return true;
4368
4435
  if (!this.enabled) return id.startsWith("page:") || id.startsWith("widget:");
@@ -5202,8 +5269,13 @@ function validateSecret(secret, add) {
5202
5269
  );
5203
5270
  } else if (secret.length < MIN_SECRET_LENGTH) {
5204
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);
5205
5274
  }
5206
5275
  }
5276
+ function guessable(secret) {
5277
+ return /change[-_ ]?me|example|placeholder|secret-?key/i.test(secret) || new Set(secret).size < 8;
5278
+ }
5207
5279
  function validateAdmin(config, add) {
5208
5280
  const admin = config.admin;
5209
5281
  if (admin === void 0) return;
@@ -6584,9 +6656,10 @@ var EasyCMS = class {
6584
6656
  if (!Number.isInteger(limit) || limit < 0)
6585
6657
  throw new QueryError("limit must be a non-negative integer");
6586
6658
  if (!Number.isInteger(page) || page < 1) throw new QueryError("page must be a positive integer");
6659
+ const draft = readsDrafts(guard, options.draft);
6587
6660
  const where = this.whereFor(
6588
6661
  config,
6589
- draftWhere(config, options.draft, await this.readWhere(config, guard, options.where)),
6662
+ draftWhere(config, draft, await this.readWhere(config, guard, options.where)),
6590
6663
  options
6591
6664
  );
6592
6665
  const requested = options.sort === void 0 ? ["-createdAt"] : [options.sort].flat();
@@ -6599,8 +6672,8 @@ var EasyCMS = class {
6599
6672
  this.config.localization?.locales ?? []
6600
6673
  );
6601
6674
  const result = await this.db.find({ collection: collection2, where, sort, limit, page });
6602
- const found = options.draft ? await this.withDrafts(config, result.docs) : result.docs;
6603
- const docs = await this.output(config, found, guard, options);
6675
+ const found = draft ? await this.withDrafts(config, result.docs) : result.docs;
6676
+ const docs = await this.output(config, found, guard, { ...options, draft });
6604
6677
  return { ...result, docs };
6605
6678
  }
6606
6679
  async findById(collection2, id, options = {}) {
@@ -6608,9 +6681,10 @@ var EasyCMS = class {
6608
6681
  const guard = guardOf(options);
6609
6682
  const parsed = parseId(id);
6610
6683
  if (parsed === void 0) return null;
6684
+ const draft = readsDrafts(guard, options.draft);
6611
6685
  const where = this.whereFor(
6612
6686
  config,
6613
- draftWhere(config, options.draft, await this.readWhere(config, guard, void 0)),
6687
+ draftWhere(config, draft, await this.readWhere(config, guard, void 0)),
6614
6688
  options
6615
6689
  );
6616
6690
  const doc = where ? (await this.db.find({
@@ -6621,8 +6695,8 @@ var EasyCMS = class {
6621
6695
  page: 1
6622
6696
  })).docs[0] : await this.db.findById({ collection: collection2, id: parsed });
6623
6697
  if (!doc) return null;
6624
- const [current] = options.draft ? await this.withDrafts(config, [doc]) : [doc];
6625
- const [out] = await this.output(config, [current], guard, options);
6698
+ const [current] = draft ? await this.withDrafts(config, [doc]) : [doc];
6699
+ const [out] = await this.output(config, [current], guard, { ...options, draft });
6626
6700
  return out ?? null;
6627
6701
  }
6628
6702
  async count(collection2, options = {}) {
@@ -6631,7 +6705,7 @@ var EasyCMS = class {
6631
6705
  config,
6632
6706
  draftWhere(
6633
6707
  config,
6634
- options.draft,
6708
+ readsDrafts(guardOf(options), options.draft),
6635
6709
  await this.readWhere(config, guardOf(options), options.where)
6636
6710
  ),
6637
6711
  options
@@ -6782,6 +6856,8 @@ var EasyCMS = class {
6782
6856
  const mimeType = sniffMimeType(start.body.subarray(0, SNIFF_BYTES), t.name);
6783
6857
  if (mimeType !== t.type)
6784
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}`);
6785
6861
  const stored = [];
6786
6862
  try {
6787
6863
  let dimensions;
@@ -7333,6 +7409,7 @@ var EasyCMS = class {
7333
7409
  raw = rest;
7334
7410
  }
7335
7411
  const { input, password } = splitPassword(config, raw);
7412
+ if (config.slug === USERS) await this.checkOwnCredentials(guard, existing, raw, password);
7336
7413
  const filtered = await filterInput(
7337
7414
  config.fields,
7338
7415
  input,
@@ -7492,7 +7569,7 @@ var EasyCMS = class {
7492
7569
  /** A global as stored under `key` (see `globalKey`); empty when there is no key. */
7493
7570
  async readGlobal(config, key2, guard, options) {
7494
7571
  const saved = key2 ? await this.db.findGlobal({ slug: key2 }) : null;
7495
- const stored = saved && options.draft ? await this.globalDraft(config, saved, key2) : saved;
7572
+ const stored = saved && readsDrafts(guard, options.draft) ? await this.globalDraft(config, saved, key2) : saved;
7496
7573
  const data = fillMissing(
7497
7574
  config.fields,
7498
7575
  applyDefaults(config.fields, stored ?? {}, this.config.localization)
@@ -7652,6 +7729,33 @@ var EasyCMS = class {
7652
7729
  await this.audited(config, "update", doc, current, guard, mode);
7653
7730
  return this.readGlobal(config, key2, guard, { ...options, draft: true });
7654
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
+ }
7655
7759
  /**
7656
7760
  * What `user` may do with one document. Resolves `where`-style access
7657
7761
  * against the document, so the admin UI can hide actions precisely.
@@ -8220,6 +8324,7 @@ var EasyCMS = class {
8220
8324
  );
8221
8325
  return await stripFields(target.fields, hooked, read2);
8222
8326
  };
8327
+ const drafts = readsDrafts(guard, options.draft);
8223
8328
  const load = async (target, ids) => {
8224
8329
  const where = await this.readWhere(target, guard, { id: { in: [...ids] } }).catch((error) => {
8225
8330
  if (error instanceof ForbiddenError || error instanceof UnauthorizedError) return null;
@@ -8228,12 +8333,12 @@ var EasyCMS = class {
8228
8333
  if (where === null) return [];
8229
8334
  const found = await this.db.find({
8230
8335
  collection: target.slug,
8231
- where: this.whereFor(target, draftWhere(target, options.draft, where), options),
8336
+ where: this.whereFor(target, draftWhere(target, drafts, where), options),
8232
8337
  sort: [],
8233
8338
  limit: 0,
8234
8339
  page: 1
8235
8340
  });
8236
- const docs2 = options.draft ? await this.withDrafts(target, found.docs) : found.docs;
8341
+ const docs2 = drafts ? await this.withDrafts(target, found.docs) : found.docs;
8237
8342
  return Promise.all(
8238
8343
  docs2.map((d) => finish(target, this.pick(target, d, options)))
8239
8344
  );
@@ -8761,12 +8866,17 @@ function guardOf(options) {
8761
8866
  context: options.context ?? NO_CONTEXT
8762
8867
  };
8763
8868
  }
8869
+ function readsDrafts(guard, draft) {
8870
+ return draft === true && (!guard.enforce || guard.user !== null && guard.user.member !== true);
8871
+ }
8764
8872
  function deny(user) {
8765
8873
  return user ? new ForbiddenError() : new UnauthorizedError();
8766
8874
  }
8767
8875
  function splitPassword(config, raw) {
8768
- if (config.slug !== USERS || !Object.hasOwn(raw, "password")) return { input: raw };
8769
- 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;
8770
8880
  if (password === void 0 || password === null || password === "") return { input };
8771
8881
  if (typeof password !== "string" || password.length < MIN_PASSWORD_LENGTH) {
8772
8882
  throw new ValidationError(USERS, [
@@ -9517,7 +9627,7 @@ async function adminSearch(cms, who, q, perCollection = 5) {
9517
9627
  }
9518
9628
 
9519
9629
  // src/version.ts
9520
- var VERSION = true ? "0.47.3" : "0.0.0-dev";
9630
+ var VERSION = true ? "0.48.0" : "0.0.0-dev";
9521
9631
 
9522
9632
  // src/rest/admin-status.ts
9523
9633
  var DAY2 = 864e5;
@@ -9860,7 +9970,8 @@ async function customEndpoint(cms, ctx, method, segments, root, options) {
9860
9970
  context: ctx.context,
9861
9971
  ip: options.getClientIp?.(ctx.request),
9862
9972
  cms,
9863
- json: () => readJson(ctx.request)
9973
+ json: () => readJson(ctx.request),
9974
+ text: async () => new TextDecoder().decode(await readBody(ctx.request, MAX_BODY_BYTES))
9864
9975
  });
9865
9976
  return { body: body === void 0 ? null : body };
9866
9977
  }
@@ -10703,12 +10814,16 @@ async function readUpload(request, maxFileSize) {
10703
10814
  415
10704
10815
  );
10705
10816
  }
10706
- const declared = Number(request.headers.get("content-length") ?? 0);
10707
- if (declared > maxFileSize + 64 * 1024)
10708
- throw new PayloadTooLargeError(`File is larger than ${maxFileSize} bytes`);
10817
+ const bytes = await readBody(request, maxFileSize + 64 * 1024).catch((error) => {
10818
+ throw error instanceof HttpError && error.status === 413 ? new PayloadTooLargeError(`File is larger than ${maxFileSize} bytes`) : error;
10819
+ });
10709
10820
  let form;
10710
10821
  try {
10711
- form = await request.formData();
10822
+ form = await new Request(request.url, {
10823
+ method: "POST",
10824
+ headers: { "content-type": request.headers.get("content-type") ?? "" },
10825
+ body: bytes
10826
+ }).formData();
10712
10827
  } catch {
10713
10828
  throw new HttpError("Malformed multipart body", 400);
10714
10829
  }
@@ -10724,15 +10839,37 @@ async function readUpload(request, maxFileSize) {
10724
10839
  data
10725
10840
  };
10726
10841
  }
10842
+ async function readBody(request, max) {
10843
+ const tooLarge = () => new HttpError("Request body too large", 413);
10844
+ if (Number(request.headers.get("content-length") ?? 0) > max) throw tooLarge();
10845
+ if (!request.body) return new Uint8Array();
10846
+ const reader = request.body.getReader();
10847
+ const chunks = [];
10848
+ let size = 0;
10849
+ for (; ; ) {
10850
+ const { done, value } = await reader.read();
10851
+ if (done) break;
10852
+ size += value.byteLength;
10853
+ if (size > max) {
10854
+ reader.releaseLock();
10855
+ throw tooLarge();
10856
+ }
10857
+ chunks.push(value);
10858
+ }
10859
+ const body = new Uint8Array(size);
10860
+ let at = 0;
10861
+ for (const chunk of chunks) {
10862
+ body.set(chunk, at);
10863
+ at += chunk.byteLength;
10864
+ }
10865
+ return body;
10866
+ }
10727
10867
  async function readJson(request) {
10728
10868
  const type = request.headers.get("content-type") ?? "";
10729
10869
  if (!type.toLowerCase().startsWith("application/json")) {
10730
10870
  throw new HttpError("Content-Type must be application/json", 415);
10731
10871
  }
10732
- const declared = Number(request.headers.get("content-length") ?? 0);
10733
- if (declared > MAX_BODY_BYTES) throw new HttpError("Request body too large", 413);
10734
- const text2 = await request.text();
10735
- if (Buffer.byteLength(text2) > MAX_BODY_BYTES) throw new HttpError("Request body too large", 413);
10872
+ const text2 = new TextDecoder().decode(await readBody(request, MAX_BODY_BYTES));
10736
10873
  let body;
10737
10874
  try {
10738
10875
  body = JSON.parse(text2);
@@ -10860,9 +10997,19 @@ function sharedEasyCMS(config, options) {
10860
10997
  });
10861
10998
  return promise;
10862
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
+ }
10863
11010
  function createApiHandler(config, options = {}) {
10864
11011
  const handlers = /* @__PURE__ */ new WeakMap();
10865
- 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));
10866
11013
  return async (request) => {
10867
11014
  const cms = await sharedEasyCMS(config);
10868
11015
  let handler = handlers.get(cms);
@@ -11158,6 +11305,7 @@ export {
11158
11305
  fillMissing,
11159
11306
  findConfigFile,
11160
11307
  formToken,
11308
+ forwardedClientIp,
11161
11309
  generateSlugs,
11162
11310
  generateTypes,
11163
11311
  hasRows,
@@ -11178,6 +11326,7 @@ export {
11178
11326
  mimeAllowed,
11179
11327
  mimeAllowedBy,
11180
11328
  parseId,
11329
+ platformClientIp,
11181
11330
  populate,
11182
11331
  rateKeys,
11183
11332
  readCookie,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "0.47.3",
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",