@easy-cms/core 0.27.0 → 0.29.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
@@ -238,6 +238,8 @@ interface EmailMessage {
238
238
  * development. Throw to report a failure: the message is retried later.
239
239
  */
240
240
  interface EmailAdapter {
241
+ /** What sends the email, e.g. `smtp`; shown to admins on the dashboard (System). */
242
+ readonly name?: string;
241
243
  /** The sender when a message has none, e.g. `Easy CMS <no-reply@example.com>`. */
242
244
  readonly from?: string | undefined;
243
245
  send(message: EmailMessage & {
@@ -879,6 +881,12 @@ declare class EasyCMS<C extends Config = Config> {
879
881
  data: Uint8Array;
880
882
  name: string;
881
883
  }, data?: Record<string, unknown>, options?: DepthOptions): Promise<MediaDocument>;
884
+ /**
885
+ * Downloads a file from an `http(s)` link and uploads it like `upload()`. Private network
886
+ * addresses are refused unless `upload.fromURL.allowPrivate`. Calls on behalf of a user (the
887
+ * REST API, `overrideAccess: false`) also need `upload.fromURL` and a host in its `allowedHosts`.
888
+ */
889
+ uploadFromURL(url: string, data?: Record<string, unknown>, options?: DepthOptions): Promise<MediaDocument>;
882
890
  /**
883
891
  * Creates an API key (needs `apiKeys: true`) and returns it once: only a hash is stored.
884
892
  * With access enforced the key belongs to `user`; trusted calls name the owner in `data.user`.
@@ -1560,7 +1568,7 @@ interface AdminPage {
1560
1568
  interface DashboardWidget {
1561
1569
  /** A Web Component from an admin module. */
1562
1570
  readonly component: AdminComponent;
1563
- /** Half the dashboard's width (default) or all of it. Full width on phones. */
1571
+ /** `half` (default): the side column, beside drafts and recent edits; `full`: below both. */
1564
1572
  readonly width?: 'half' | 'full';
1565
1573
  /** Who sees it. */
1566
1574
  readonly access?: AdminViewAccess;
@@ -1603,6 +1611,22 @@ interface ImageSize {
1603
1611
  /** How to fit when both width and height are set. Default `cover`. */
1604
1612
  readonly fit?: 'cover' | 'contain' | 'inside';
1605
1613
  }
1614
+ /**
1615
+ * Uploads from a link: the server downloads the file (`cms.uploadFromURL`, `POST <api>/media`
1616
+ * with `{ url }`, "From a link" in the admin).
1617
+ */
1618
+ interface UploadFromURLConfig {
1619
+ /**
1620
+ * Hosts files may come from: `images.example.com`, `*.example.com` (its subdomains, not
1621
+ * `example.com` itself) or `*` for any public host. Checked at every redirect too.
1622
+ */
1623
+ readonly allowedHosts: readonly string[];
1624
+ /**
1625
+ * Also download from private network addresses (`10.x`, `192.168.x`, `localhost`…), e.g. an
1626
+ * intranet. Off by default: links could then reach services that are not meant to be public.
1627
+ */
1628
+ readonly allowPrivate?: boolean;
1629
+ }
1606
1630
  interface UploadConfig {
1607
1631
  /** Directory for uploaded files with the default local storage, relative to the project root. Default `uploads`. */
1608
1632
  readonly dir?: string;
@@ -1614,6 +1638,8 @@ interface UploadConfig {
1614
1638
  readonly storage?: StorageAdapter;
1615
1639
  /** Resized copies generated for images when `sharp` is installed. */
1616
1640
  readonly imageSizes?: readonly ImageSize[];
1641
+ /** Lets users upload from a link. Off by default. */
1642
+ readonly fromURL?: UploadFromURLConfig;
1617
1643
  }
1618
1644
  interface RoutesConfig {
1619
1645
  /** Where the REST API is served. Default `/api/cms`. */
@@ -1702,8 +1728,17 @@ interface Endpoint {
1702
1728
  */
1703
1729
  readonly handler: (request: EndpointRequest) => MaybePromise<unknown>;
1704
1730
  }
1731
+ /** A plugin's package and version, listed for admins on the dashboard (System). */
1732
+ interface PluginInfo {
1733
+ /** Usually the package name, e.g. `@acme/easy-cms-plugin-stats`. */
1734
+ readonly name: string;
1735
+ readonly version?: string;
1736
+ }
1705
1737
  /** Receives the config and returns a modified copy. Runs before validation. */
1706
- type Plugin = (config: Config) => MaybePromise<Config>;
1738
+ type Plugin = ((config: Config) => MaybePromise<Config>) & {
1739
+ /** Set by `definePlugin(plugin, info)`. */
1740
+ readonly info?: PluginInfo;
1741
+ };
1707
1742
  /** Holds a plugin's `PluginTypes` at the type level only: nothing is stored at runtime. */
1708
1743
  declare const PLUGIN_TYPES: unique symbol;
1709
1744
  /**
@@ -1725,17 +1760,18 @@ type TypedPlugin<T extends PluginTypes> = Plugin & {
1725
1760
  readonly [PLUGIN_TYPES]?: T;
1726
1761
  };
1727
1762
  /**
1728
- * Returns the plugin unchanged, with what it adds as types, so `cms.find()` and
1729
- * `CollectionDocument` know the plugin's fields:
1763
+ * Returns the plugin with what it adds as types, so `cms.find()` and `CollectionDocument` know
1764
+ * the plugin's fields; `info` (its name and version) is listed for admins on the dashboard:
1730
1765
  *
1731
1766
  * ```ts
1732
1767
  * export const colorPlugin = (options: { collections: readonly string[] }) =>
1733
1768
  * definePlugin<{ fields: { posts: readonly [{ readonly name: 'color'; readonly type: 'text' }] } }>(
1734
1769
  * (config) => ({ ...config, collections: … }),
1770
+ * { name: '@acme/easy-cms-plugin-color', version: '1.2.0' },
1735
1771
  * )
1736
1772
  * ```
1737
1773
  */
1738
- declare function definePlugin<const T extends PluginTypes = Record<never, never>>(plugin: Plugin): TypedPlugin<T>;
1774
+ declare function definePlugin<const T extends PluginTypes = Record<never, never>>(plugin: Plugin, info?: PluginInfo): TypedPlugin<T>;
1739
1775
  interface Config {
1740
1776
  /** Signs sessions. At least 32 characters; read it from `process.env.EASY_CMS_SECRET`. */
1741
1777
  readonly secret: string;
@@ -1800,13 +1836,15 @@ interface ResolvedConfig extends Omit<Config, 'cors' | 'localization' | 'routes'
1800
1836
  readonly localization: Required<LocalizationConfig> | null;
1801
1837
  readonly routes: Required<RoutesConfig>;
1802
1838
  readonly admin: Required<AdminConfig>;
1803
- readonly upload: Required<Omit<UploadConfig, 'storage'>> & Pick<UploadConfig, 'storage'>;
1839
+ readonly upload: Required<Omit<UploadConfig, 'storage' | 'fromURL'>> & Pick<UploadConfig, 'storage' | 'fromURL'>;
1804
1840
  readonly auth: Required<AuthConfig>;
1805
1841
  readonly collections: readonly CollectionConfig[];
1806
1842
  readonly globals: readonly GlobalConfig[];
1807
1843
  readonly endpoints: readonly Endpoint[];
1808
1844
  readonly commands: readonly CliCommand[];
1809
1845
  readonly fieldTypes: readonly FieldTypeDefinition[];
1846
+ /** The config's plugins in order, with their `info` where they give one. */
1847
+ readonly installedPlugins: readonly Partial<PluginInfo>[];
1810
1848
  }
1811
1849
  /**
1812
1850
  * Declares the Easy CMS config. Returns it unchanged, keeping literal types
@@ -2130,12 +2168,62 @@ interface AdminSchema {
2130
2168
  globals: AdminGlobal[];
2131
2169
  /** URLs under the API of the admin modules to load (`admin.modules`). */
2132
2170
  modules: string[];
2171
+ /** Files can be uploaded from links (`upload.fromURL`). */
2172
+ uploadFromURL: boolean;
2133
2173
  /** Pages this user may open (`admin.pages`). */
2134
2174
  pages: AdminPageRef[];
2135
2175
  /** Dashboard panels this user may see (`admin.dashboard`). */
2136
2176
  dashboard: AdminWidgetRef[];
2137
2177
  }
2138
2178
 
2179
+ /** Something an admin should look at, shown on the dashboard with a link to how to fix it. */
2180
+ type AdminAttention =
2181
+ /** Webhook deliveries that failed for good in the last 7 days; `url`: the one failing most. */
2182
+ {
2183
+ id: 'webhooks';
2184
+ count: number;
2185
+ url: string;
2186
+ }
2187
+ /** Emails waiting over an hour, or that failed for good in the last 7 days. */
2188
+ | {
2189
+ id: 'emails';
2190
+ count: number;
2191
+ }
2192
+ /** Scheduled publishing more than 10 minutes late: nothing runs `jobs/run`. */
2193
+ | {
2194
+ id: 'scheduled';
2195
+ count: number;
2196
+ }
2197
+ /** No `email` in the config: no forgotten-password links, invitations or form emails. */
2198
+ | {
2199
+ id: 'no-email';
2200
+ }
2201
+ /** In production without `serverURL`: links in emails can't be made. */
2202
+ | {
2203
+ id: 'no-server-url';
2204
+ };
2205
+ /** What the dashboard shows admins (`GET <api>/admin/status`). */
2206
+ interface AdminStatus {
2207
+ system: {
2208
+ /** `@easy-cms/core`'s version. */
2209
+ version: string;
2210
+ /** The database adapter, e.g. `sqlite` or `postgres`. */
2211
+ database: string;
2212
+ /** Where uploads are stored, e.g. `local` or `s3`. */
2213
+ storage: string;
2214
+ /** The email adapter's name, `custom` when it has none, or `null` without email. */
2215
+ email: string | null;
2216
+ /** The config's plugins, with their name and version when they give them. */
2217
+ plugins: {
2218
+ name?: string;
2219
+ version?: string;
2220
+ }[];
2221
+ /** Field types from packages (`fieldTypes`). */
2222
+ fieldTypes: string[];
2223
+ };
2224
+ attention: AdminAttention[];
2225
+ }
2226
+
2139
2227
  declare const SESSION_COOKIE = "ecms-session";
2140
2228
  declare const CSRF_COOKIE = "ecms-csrf";
2141
2229
  declare const CSRF_HEADER = "x-csrf-token";
@@ -2171,4 +2259,7 @@ declare const BUILTIN_COLLECTIONS: readonly ["users", "media"];
2171
2259
  /** Checks a config and returns every problem found. An empty array means it is valid. */
2172
2260
  declare function validateConfig(config: Config): ConfigIssue[];
2173
2261
 
2174
- 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 AdminPage, type AdminPageRef, type AdminSchema, type AdminViewAccess, type AdminWidgetRef, 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, 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 DepthOptions, type DocumentOf, EXTENSIONS, EasyCMS, EasyCMSError, type EmailAdapter, type EmailField, type EmailMessage, type Endpoint, type EndpointRequest, FIELD_TYPES, FIELD_TYPE_BASES, type Field, type FieldAccess, type FieldAccessFn, type FieldAdmin, type FieldError, 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, 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 PluginTypes, 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 SlugOf, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, type TypedPlugin, 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, defineFieldType, definePlugin, 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 };
2262
+ /** The version of `@easy-cms/core`, set when the package is built. */
2263
+ declare const VERSION: string;
2264
+
2265
+ export { ADMIN_ICONS, API_KEYS, API_KEY_GLOBAL_OPERATIONS, API_KEY_OPERATIONS, type Access, type AccessArgs, type AccessOptions, type AdminAttention, type AdminBrand, type AdminCollection, type AdminComponent, type AdminComponentRef, type AdminConfig, type AdminField, type AdminGlobal, type AdminIcon, type AdminLocale, type AdminPage, type AdminPageRef, type AdminSchema, type AdminStatus, type AdminViewAccess, type AdminWidgetRef, 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, 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 DepthOptions, type DocumentOf, EXTENSIONS, EasyCMS, EasyCMSError, type EmailAdapter, type EmailField, type EmailMessage, type Endpoint, type EndpointRequest, FIELD_TYPES, FIELD_TYPE_BASES, type Field, type FieldAccess, type FieldAccessFn, type FieldAdmin, type FieldError, 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, 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 PluginInfo, type PluginTypes, 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 SlugOf, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, type TypedPlugin, UnauthorizedError, type UpdateInput, type UpdateOptions, type UploadConfig, type UploadField, type UploadFromURLConfig, VERSION, 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, defineFieldType, definePlugin, 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
@@ -271,22 +271,22 @@ var FieldAccessChecker = class {
271
271
  return allowed2;
272
272
  }
273
273
  };
274
- async function stripFields(fields, doc, read) {
274
+ async function stripFields(fields, doc, read2) {
275
275
  const result = { ...doc };
276
276
  for (const field of fields) {
277
277
  if (!(field.name in result)) continue;
278
- if (field.hidden || read && !await read.allows(field)) {
278
+ if (field.hidden || read2 && !await read2.allows(field)) {
279
279
  delete result[field.name];
280
280
  continue;
281
281
  }
282
282
  const value = result[field.name];
283
283
  if (field.type === "group" && value && typeof value === "object") {
284
- result[field.name] = await stripFields(field.fields, value, read);
284
+ result[field.name] = await stripFields(field.fields, value, read2);
285
285
  } else if (hasRows(field) && Array.isArray(value)) {
286
286
  result[field.name] = await Promise.all(
287
287
  value.map((row) => {
288
288
  const fields2 = rowFields(field, row);
289
- return fields2 && row && typeof row === "object" ? stripFields(fields2, row, read) : row;
289
+ return fields2 && row && typeof row === "object" ? stripFields(fields2, row, read2) : row;
290
290
  })
291
291
  );
292
292
  }
@@ -1142,7 +1142,8 @@ var ADMIN_ICONS = [
1142
1142
  "chart-column",
1143
1143
  "chart-line"
1144
1144
  ];
1145
- function definePlugin(plugin) {
1145
+ function definePlugin(plugin, info) {
1146
+ if (info) Object.defineProperty(plugin, "info", { value: info, configurable: true });
1146
1147
  return plugin;
1147
1148
  }
1148
1149
  function defineConfig(config) {
@@ -1304,13 +1305,13 @@ function generateSlugs(fields, data, localization = null) {
1304
1305
  function mergeForUpdate(fields, existing, patch) {
1305
1306
  const result = {};
1306
1307
  for (const field of fields) {
1307
- const has = Object.hasOwn(patch, field.name);
1308
+ const has2 = Object.hasOwn(patch, field.name);
1308
1309
  const next = patch[field.name];
1309
1310
  const prev = existing[field.name];
1310
- if (field.type === "group" && has && isPlainObject(next)) {
1311
+ if (field.type === "group" && has2 && isPlainObject(next)) {
1311
1312
  result[field.name] = mergeForUpdate(field.fields, isPlainObject(prev) ? prev : {}, next);
1312
1313
  } else {
1313
- result[field.name] = has ? next : prev;
1314
+ result[field.name] = has2 ? next : prev;
1314
1315
  }
1315
1316
  }
1316
1317
  return result;
@@ -1541,6 +1542,7 @@ function normalizeSelect(field, raw, fail) {
1541
1542
  function consoleEmail(options = {}) {
1542
1543
  const sent = [];
1543
1544
  const adapter = {
1545
+ name: "console",
1544
1546
  from: options.from ?? "Easy CMS <no-reply@localhost>",
1545
1547
  sent,
1546
1548
  async send(message) {
@@ -1744,7 +1746,7 @@ function applyFieldTypes(config) {
1744
1746
  const problem = type.checkOptions?.(own);
1745
1747
  if (problem) issues.push({ path: here, message: problem });
1746
1748
  const ownValidate = own.validate;
1747
- const check = type.validate;
1749
+ const check2 = type.validate;
1748
1750
  const admin = own.admin ?? {};
1749
1751
  const input = admin.component ?? component(type.admin?.component, own, type.admin?.props);
1750
1752
  const cell = admin.cell ?? component(type.admin?.cell, own, type.admin?.props);
@@ -1752,11 +1754,11 @@ function applyFieldTypes(config) {
1752
1754
  ...own,
1753
1755
  type: type.base,
1754
1756
  customType: type.name,
1755
- ...check || ownValidate ? {
1757
+ ...check2 || ownValidate ? {
1756
1758
  validate: async (value, ctx) => {
1757
1759
  const empty = value === null || value === void 0 || value === "";
1758
- if (check && !empty) {
1759
- const result = await check(value, { ...ctx, field: own });
1760
+ if (check2 && !empty) {
1761
+ const result = await check2(value, { ...ctx, field: own });
1760
1762
  if (result !== true) return result;
1761
1763
  }
1762
1764
  return ownValidate ? ownValidate(value, ctx) : true;
@@ -2388,7 +2390,32 @@ function validateUpload(config, add) {
2388
2390
  if (upload.dir !== void 0 && (typeof upload.dir !== "string" || upload.dir.length === 0)) {
2389
2391
  add("upload.dir", "must be a non-empty string");
2390
2392
  }
2393
+ const fromURL = upload.fromURL;
2394
+ if (fromURL === void 0) return;
2395
+ if (typeof fromURL !== "object" || fromURL === null) {
2396
+ add("upload.fromURL", "must be an object", "e.g. fromURL: { allowedHosts: ['*'] }");
2397
+ return;
2398
+ }
2399
+ const { allowedHosts, allowPrivate } = fromURL;
2400
+ if (!Array.isArray(allowedHosts) || allowedHosts.length === 0) {
2401
+ add(
2402
+ "upload.fromURL.allowedHosts",
2403
+ "must be a non-empty array of host names",
2404
+ "e.g. allowedHosts: ['images.example.com', '*.cdn.example.com'] or ['*'] for any public host"
2405
+ );
2406
+ } else {
2407
+ allowedHosts.forEach((host, i) => {
2408
+ if (typeof host !== "string" || !HOST_PATTERN.test(host))
2409
+ add(
2410
+ `upload.fromURL.allowedHosts[${i}]`,
2411
+ `must be a host name like "images.example.com", "*.example.com" or "*" (got ${JSON.stringify(host)})`
2412
+ );
2413
+ });
2414
+ }
2415
+ if (allowPrivate !== void 0 && typeof allowPrivate !== "boolean")
2416
+ add("upload.fromURL.allowPrivate", "must be true or false");
2391
2417
  }
2418
+ var HOST_PATTERN = /^(\*|(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*)$/i;
2392
2419
  function asArray(value, path, add) {
2393
2420
  if (value === void 0) return [];
2394
2421
  if (!Array.isArray(value)) {
@@ -2815,7 +2842,7 @@ async function resolveConfig(input) {
2815
2842
  config = applied.config;
2816
2843
  const issues = [...applied.issues, ...validateConfig(config)];
2817
2844
  if (issues.length > 0) throw new ConfigError(issues);
2818
- const { plugins: _plugins, ...rest } = config;
2845
+ const { plugins, ...rest } = config;
2819
2846
  const result = {
2820
2847
  ...rest,
2821
2848
  cors: config.cors ?? [],
@@ -2840,7 +2867,8 @@ async function resolveConfig(input) {
2840
2867
  maxFileSize: config.upload?.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
2841
2868
  mimeTypes: config.upload?.mimeTypes ?? ["image/*", "application/pdf"],
2842
2869
  imageSizes: config.upload?.imageSizes ?? [],
2843
- ...config.upload?.storage ? { storage: config.upload.storage } : {}
2870
+ ...config.upload?.storage ? { storage: config.upload.storage } : {},
2871
+ ...config.upload?.fromURL ? { fromURL: config.upload.fromURL } : {}
2844
2872
  },
2845
2873
  auth: {
2846
2874
  roles: config.auth?.roles ?? DEFAULT_ROLES,
@@ -2864,6 +2892,9 @@ async function resolveConfig(input) {
2864
2892
  globals: config.globals ?? [],
2865
2893
  endpoints: config.endpoints ?? [],
2866
2894
  commands: config.commands ?? [],
2895
+ installedPlugins: (plugins ?? []).map(
2896
+ (plugin) => typeof plugin === "function" && plugin.info ? { ...plugin.info } : {}
2897
+ ),
2867
2898
  fieldTypes: config.fieldTypes ?? []
2868
2899
  };
2869
2900
  resolved.add(result);
@@ -3195,6 +3226,212 @@ function collectSlots(fields, data, bySlug, slots) {
3195
3226
  }
3196
3227
  }
3197
3228
 
3229
+ // src/remote-file.ts
3230
+ import { lookup as dnsLookup } from "dns";
3231
+ import { request as httpRequest } from "http";
3232
+ import { request as httpsRequest } from "https";
3233
+ import { BlockList, isIP } from "net";
3234
+ var RemoteFileError = class extends Error {
3235
+ /** `413` when the file is too large, otherwise `400`. */
3236
+ status;
3237
+ constructor(message, status = 400) {
3238
+ super(message);
3239
+ this.name = "RemoteFileError";
3240
+ this.status = status;
3241
+ }
3242
+ };
3243
+ var PRIVATE = new BlockList();
3244
+ for (const [net, prefix] of [
3245
+ ["0.0.0.0", 8],
3246
+ ["10.0.0.0", 8],
3247
+ ["100.64.0.0", 10],
3248
+ ["127.0.0.0", 8],
3249
+ ["169.254.0.0", 16],
3250
+ ["172.16.0.0", 12],
3251
+ ["192.0.0.0", 24],
3252
+ ["192.0.2.0", 24],
3253
+ ["192.168.0.0", 16],
3254
+ ["198.18.0.0", 15],
3255
+ ["198.51.100.0", 24],
3256
+ ["203.0.113.0", 24],
3257
+ ["224.0.0.0", 4],
3258
+ ["240.0.0.0", 4]
3259
+ ])
3260
+ PRIVATE.addSubnet(net, prefix, "ipv4");
3261
+ for (const [net, prefix] of [
3262
+ ["::", 128],
3263
+ ["::1", 128],
3264
+ ["64:ff9b:1::", 48],
3265
+ ["100::", 64],
3266
+ ["2001:db8::", 32],
3267
+ ["fc00::", 7],
3268
+ ["fe80::", 10],
3269
+ ["ff00::", 8]
3270
+ ])
3271
+ PRIVATE.addSubnet(net, prefix, "ipv6");
3272
+ function isPrivateAddress(address) {
3273
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address);
3274
+ if (mapped?.[1]) return PRIVATE.check(mapped[1], "ipv4");
3275
+ const family = isIP(address);
3276
+ if (family === 4) return PRIVATE.check(address, "ipv4");
3277
+ if (family === 6) return PRIVATE.check(address, "ipv6");
3278
+ return true;
3279
+ }
3280
+ function hostAllowed(host, allowedHosts) {
3281
+ const name = host.toLowerCase().replace(/\.$/, "");
3282
+ return allowedHosts.some((rule) => {
3283
+ const pattern = rule.toLowerCase();
3284
+ if (pattern === "*") return true;
3285
+ if (pattern.startsWith("*.")) return name.endsWith(pattern.slice(1));
3286
+ return name === pattern;
3287
+ });
3288
+ }
3289
+ function guardedLookup(allowPrivate) {
3290
+ return (hostname, options, callback) => {
3291
+ dnsLookup(hostname, { ...options, all: true }, (error, addresses) => {
3292
+ if (error) return callback(error, "", 4);
3293
+ const usable = allowPrivate ? addresses : addresses.filter((a) => !isPrivateAddress(a.address));
3294
+ if (usable.length === 0) {
3295
+ const blocked = new Error(`${hostname} points to a private network address`);
3296
+ blocked.code = "ECMS_PRIVATE";
3297
+ return callback(blocked, "", 4);
3298
+ }
3299
+ if (options.all) return callback(null, usable, 4);
3300
+ const first = usable[0];
3301
+ callback(null, first.address, first.family);
3302
+ });
3303
+ };
3304
+ }
3305
+ function remoteFileName(url, disposition) {
3306
+ if (disposition) {
3307
+ const star = /filename\*\s*=\s*(?:UTF-8|utf-8)''([^;]+)/.exec(disposition);
3308
+ if (star?.[1]) {
3309
+ try {
3310
+ return clean(decodeURIComponent(star[1].trim()));
3311
+ } catch {
3312
+ }
3313
+ }
3314
+ const plain = /filename\s*=\s*"?([^";]+)"?/.exec(disposition);
3315
+ if (plain?.[1]) return clean(plain[1].trim());
3316
+ }
3317
+ const last = url.pathname.split("/").filter(Boolean).pop();
3318
+ if (last) {
3319
+ try {
3320
+ return clean(decodeURIComponent(last));
3321
+ } catch {
3322
+ return clean(last);
3323
+ }
3324
+ }
3325
+ return "download";
3326
+ }
3327
+ var clean = (name) => name.replace(/[\\/]/g, "_").slice(0, 200) || "download";
3328
+ async function fetchRemoteFile(link, options) {
3329
+ let url;
3330
+ try {
3331
+ url = new URL(link);
3332
+ } catch {
3333
+ throw new RemoteFileError("must be an http(s) link");
3334
+ }
3335
+ const deadline = Date.now() + (options.timeout ?? 15e3);
3336
+ const maxRedirects = options.maxRedirects ?? 3;
3337
+ for (let redirects = 0; ; redirects++) {
3338
+ check(url, options);
3339
+ const response = await get(url, options, deadline);
3340
+ const status = response.statusCode ?? 0;
3341
+ if (status >= 300 && status < 400 && response.headers.location) {
3342
+ response.resume();
3343
+ if (redirects >= maxRedirects) throw new RemoteFileError("redirects too many times");
3344
+ url = new URL(response.headers.location, url);
3345
+ continue;
3346
+ }
3347
+ if (status < 200 || status >= 300) {
3348
+ response.resume();
3349
+ throw new RemoteFileError(`could not be downloaded (HTTP ${status})`);
3350
+ }
3351
+ const data = await read(response, options.maxBytes, deadline);
3352
+ const contentType = response.headers["content-type"];
3353
+ return {
3354
+ data,
3355
+ name: remoteFileName(url, response.headers["content-disposition"]),
3356
+ ...contentType ? { contentType } : {}
3357
+ };
3358
+ }
3359
+ }
3360
+ function check(url, options) {
3361
+ if (url.protocol !== "http:" && url.protocol !== "https:")
3362
+ throw new RemoteFileError("must be an http(s) link");
3363
+ if (url.username || url.password) throw new RemoteFileError("must not contain a password");
3364
+ const host = url.hostname.replace(/^\[|\]$/g, "");
3365
+ if (options.allowedHosts && !hostAllowed(host, options.allowedHosts))
3366
+ throw new RemoteFileError(`${host} is not an allowed host`);
3367
+ if (!options.allowPrivate && isIP(host) && isPrivateAddress(host))
3368
+ throw new RemoteFileError(`${host} is a private network address`);
3369
+ }
3370
+ function get(url, options, deadline) {
3371
+ const request = url.protocol === "https:" ? httpsRequest : httpRequest;
3372
+ return new Promise((resolve3, reject) => {
3373
+ const req = request(url, {
3374
+ method: "GET",
3375
+ lookup: guardedLookup(options.allowPrivate === true),
3376
+ headers: {
3377
+ "user-agent": options.userAgent ?? "EasyCMS",
3378
+ accept: "*/*"
3379
+ }
3380
+ });
3381
+ const timer = setTimeout(
3382
+ () => req.destroy(new RemoteFileError("took too long to download")),
3383
+ Math.max(0, deadline - Date.now())
3384
+ );
3385
+ req.on("response", (response) => {
3386
+ clearTimeout(timer);
3387
+ resolve3(response);
3388
+ });
3389
+ req.on("error", (error) => {
3390
+ clearTimeout(timer);
3391
+ if (error instanceof RemoteFileError) reject(error);
3392
+ else if (error.code === "ECMS_PRIVATE")
3393
+ reject(new RemoteFileError(`${url.hostname} points to a private network address`));
3394
+ else if (error.code === "ENOTFOUND" || error.code === "EAI_AGAIN")
3395
+ reject(new RemoteFileError(`${url.hostname} was not found`));
3396
+ else reject(new RemoteFileError("could not be downloaded"));
3397
+ });
3398
+ req.end();
3399
+ });
3400
+ }
3401
+ function read(response, maxBytes, deadline) {
3402
+ const declared = Number(response.headers["content-length"]);
3403
+ if (Number.isFinite(declared) && declared > maxBytes) {
3404
+ response.destroy();
3405
+ return Promise.reject(new RemoteFileError(`is larger than ${maxBytes} bytes`, 413));
3406
+ }
3407
+ return new Promise((resolve3, reject) => {
3408
+ const chunks = [];
3409
+ let size = 0;
3410
+ const timer = setTimeout(
3411
+ () => response.destroy(new RemoteFileError("took too long to download")),
3412
+ Math.max(0, deadline - Date.now())
3413
+ );
3414
+ response.on("data", (chunk) => {
3415
+ size += chunk.byteLength;
3416
+ if (size > maxBytes) {
3417
+ response.destroy(new RemoteFileError(`is larger than ${maxBytes} bytes`, 413));
3418
+ return;
3419
+ }
3420
+ chunks.push(chunk);
3421
+ });
3422
+ response.on("end", () => {
3423
+ clearTimeout(timer);
3424
+ resolve3(new Uint8Array(Buffer.concat(chunks)));
3425
+ });
3426
+ response.on("error", (error) => {
3427
+ clearTimeout(timer);
3428
+ reject(
3429
+ error instanceof RemoteFileError ? error : new RemoteFileError("could not be downloaded")
3430
+ );
3431
+ });
3432
+ });
3433
+ }
3434
+
3198
3435
  // src/storage.ts
3199
3436
  import { mkdir, readFile, rm, stat, writeFile } from "fs/promises";
3200
3437
  import { dirname, isAbsolute, join, normalize, resolve as resolve2, sep } from "path";
@@ -3557,6 +3794,39 @@ var EasyCMS = class {
3557
3794
  throw error;
3558
3795
  }
3559
3796
  }
3797
+ /**
3798
+ * Downloads a file from an `http(s)` link and uploads it like `upload()`. Private network
3799
+ * addresses are refused unless `upload.fromURL.allowPrivate`. Calls on behalf of a user (the
3800
+ * REST API, `overrideAccess: false`) also need `upload.fromURL` and a host in its `allowedHosts`.
3801
+ */
3802
+ async uploadFromURL(url, data = {}, options = {}) {
3803
+ const config = this.collection(MEDIA);
3804
+ const guard = guardOf(options);
3805
+ const fromURL = this.config.upload.fromURL;
3806
+ if (guard.enforce) {
3807
+ this.checkKey(guard, { collection: MEDIA }, "create");
3808
+ const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data });
3809
+ if (allowed2 !== true) throw deny(guard.user);
3810
+ if (!fromURL)
3811
+ throw new ValidationError(MEDIA, [
3812
+ { field: "url", message: "uploads from links are off (upload.fromURL)" }
3813
+ ]);
3814
+ }
3815
+ let file;
3816
+ try {
3817
+ file = await fetchRemoteFile(url, {
3818
+ allowedHosts: guard.enforce ? fromURL?.allowedHosts : void 0,
3819
+ allowPrivate: fromURL?.allowPrivate === true,
3820
+ maxBytes: this.config.upload.maxFileSize,
3821
+ userAgent: "EasyCMS (+https://github.com/maritonx/easy-cms)"
3822
+ });
3823
+ } catch (error) {
3824
+ if (!(error instanceof RemoteFileError)) throw error;
3825
+ if (error.status === 413) throw new PayloadTooLargeError(`File ${error.message}`);
3826
+ throw new ValidationError(MEDIA, [{ field: "url", message: error.message }]);
3827
+ }
3828
+ return this.upload(file, data, options);
3829
+ }
3560
3830
  /**
3561
3831
  * Creates an API key (needs `apiKeys: true`) and returns it once: only a hash is stored.
3562
3832
  * With access enforced the key belongs to `user`; trusted calls name the owner in `data.user`.
@@ -3766,7 +4036,7 @@ var EasyCMS = class {
3766
4036
  /** Coerces preview input like a save would, keeping values that would fail validation. */
3767
4037
  async previewDoc(config, data, system2, options = {}) {
3768
4038
  const locale = this.localeOf(options);
3769
- const { data: clean } = await validateFields(config.fields, data, {
4039
+ const { data: clean2 } = await validateFields(config.fields, data, {
3770
4040
  operation: "update",
3771
4041
  root: data,
3772
4042
  skipRequired: true,
@@ -3775,7 +4045,7 @@ var EasyCMS = class {
3775
4045
  });
3776
4046
  return {
3777
4047
  ...data,
3778
- ...clean,
4048
+ ...clean2,
3779
4049
  ...config.drafts ? { status: data.status } : {},
3780
4050
  ...system2,
3781
4051
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -4064,14 +4334,14 @@ var EasyCMS = class {
4064
4334
  const parsed = parseId(id);
4065
4335
  if (parsed === void 0) return { update: false, delete: false };
4066
4336
  const guard = { enforce: true, user };
4067
- const check = (operation) => this.checkDocumentAccess(config, operation, guard, parsed, void 0).then(
4337
+ const check2 = (operation) => this.checkDocumentAccess(config, operation, guard, parsed, void 0).then(
4068
4338
  () => true,
4069
4339
  (error) => {
4070
4340
  if (error instanceof ForbiddenError || error instanceof UnauthorizedError) return false;
4071
4341
  throw error;
4072
4342
  }
4073
4343
  );
4074
- return { update: await check("update"), delete: await check("delete") };
4344
+ return { update: await check2("update"), delete: await check2("delete") };
4075
4345
  }
4076
4346
  /** Closes the database connection. */
4077
4347
  /**
@@ -4445,7 +4715,7 @@ var EasyCMS = class {
4445
4715
  }
4446
4716
  /** Runs afterRead hooks, populates relationships and removes what the caller may not see. */
4447
4717
  async output(config, docs, guard, options) {
4448
- const read = this.fieldChecker("read", guard, void 0, void 0);
4718
+ const read2 = this.fieldChecker("read", guard, void 0, void 0);
4449
4719
  const finish = async (target, doc) => {
4450
4720
  const hooked = await this.transform(
4451
4721
  target.hooks?.afterRead,
@@ -4453,7 +4723,7 @@ var EasyCMS = class {
4453
4723
  this.hookArgs(target, guard),
4454
4724
  doc
4455
4725
  );
4456
- return await stripFields(target.fields, hooked, read);
4726
+ return await stripFields(target.fields, hooked, read2);
4457
4727
  };
4458
4728
  const load = async (target, ids) => {
4459
4729
  const where = await this.readWhere(target, guard, { id: { in: [...ids] } }).catch((error) => {
@@ -4696,24 +4966,24 @@ var EasyCMS = class {
4696
4966
  ...locale !== void 0 && locale !== ALL_LOCALES ? { locale } : {}
4697
4967
  });
4698
4968
  const errors = [...result.errors];
4699
- const clean = { ...result.data };
4969
+ const clean2 = { ...result.data };
4700
4970
  if (config.drafts) {
4701
4971
  const status = data.status ?? "draft";
4702
4972
  if (status !== "draft" && status !== "published") {
4703
4973
  errors.push({ field: "status", message: 'must be "draft" or "published"' });
4704
4974
  }
4705
- clean.status = status;
4975
+ clean2.status = status;
4706
4976
  }
4707
4977
  const isCollection = this.config.collections.includes(config);
4708
4978
  if (isCollection && errors.length === 0) {
4709
- await this.makeSlugsUnique(config, clean, selfId);
4710
- errors.push(...await this.checkUnique(config, clean, selfId));
4979
+ await this.makeSlugsUnique(config, clean2, selfId);
4980
+ errors.push(...await this.checkUnique(config, clean2, selfId));
4711
4981
  }
4712
4982
  errors.push(...await this.checkReferences(result.references));
4713
4983
  if (errors.length === 0)
4714
4984
  errors.push(...await this.checkFilterOptions(result.references, selfId, user));
4715
4985
  if (errors.length > 0) throw new ValidationError(config.slug, errors);
4716
- return clean;
4986
+ return clean2;
4717
4987
  }
4718
4988
  /** Refuses changes that would leave no active admin (and lock everyone out). */
4719
4989
  async guardLastAdmin(id, before, after) {
@@ -5084,6 +5354,7 @@ async function adminSchema(cms, user, origin) {
5084
5354
  collections: await Promise.all(collections.map((c) => collection(c, user, localized))),
5085
5355
  globals: await Promise.all(cms.config.globals.map((g) => global(g, user, localized))),
5086
5356
  modules: adminModuleUrls(cms),
5357
+ uploadFromURL: cms.config.upload.fromURL !== void 0,
5087
5358
  pages: await pages(cms, user),
5088
5359
  dashboard: await widgets(cms, user)
5089
5360
  };
@@ -5119,6 +5390,80 @@ async function widgets(cms, user) {
5119
5390
  return out;
5120
5391
  }
5121
5392
 
5393
+ // src/version.ts
5394
+ var VERSION = true ? "0.29.0" : "0.0.0-dev";
5395
+
5396
+ // src/rest/admin-status.ts
5397
+ var DAY = 864e5;
5398
+ var has = (cms, slug) => cms.config.collections.some((c) => c.slug === slug);
5399
+ async function adminStatus(cms) {
5400
+ const config = cms.config;
5401
+ const attention = [];
5402
+ const now = Date.now();
5403
+ const weekAgo = new Date(now - 7 * DAY).toISOString();
5404
+ if (has(cms, WEBHOOK_DELIVERIES)) {
5405
+ const failed = await cms.db.find({
5406
+ collection: WEBHOOK_DELIVERIES,
5407
+ where: { and: [{ state: { equals: "failed" } }, { updatedAt: { gte: weekAgo } }] },
5408
+ sort: ["-updatedAt"],
5409
+ limit: 200,
5410
+ page: 1
5411
+ });
5412
+ if (failed.totalDocs > 0) {
5413
+ const byUrl = /* @__PURE__ */ new Map();
5414
+ for (const row of failed.docs) {
5415
+ const url2 = String(row.url ?? "");
5416
+ byUrl.set(url2, (byUrl.get(url2) ?? 0) + 1);
5417
+ }
5418
+ const [url] = [...byUrl.entries()].sort((a, b) => b[1] - a[1])[0] ?? [""];
5419
+ attention.push({ id: "webhooks", count: failed.totalDocs, url });
5420
+ }
5421
+ }
5422
+ if (has(cms, EMAIL_DELIVERIES)) {
5423
+ const count = await cms.db.count({
5424
+ collection: EMAIL_DELIVERIES,
5425
+ where: {
5426
+ or: [
5427
+ { and: [{ state: { equals: "failed" } }, { updatedAt: { gte: weekAgo } }] },
5428
+ {
5429
+ and: [
5430
+ { state: { equals: "pending" } },
5431
+ { createdAt: { lte: new Date(now - 60 * 6e4).toISOString() } }
5432
+ ]
5433
+ }
5434
+ ]
5435
+ }
5436
+ });
5437
+ if (count > 0) attention.push({ id: "emails", count });
5438
+ }
5439
+ if (has(cms, SCHEDULED_JOBS)) {
5440
+ const count = await cms.db.count({
5441
+ collection: SCHEDULED_JOBS,
5442
+ where: {
5443
+ and: [
5444
+ { state: { equals: "pending" } },
5445
+ { runAt: { lte: new Date(now - 10 * 6e4).toISOString() } }
5446
+ ]
5447
+ }
5448
+ });
5449
+ if (count > 0) attention.push({ id: "scheduled", count });
5450
+ }
5451
+ if (!config.email) attention.push({ id: "no-email" });
5452
+ if (!config.serverURL && process.env.NODE_ENV === "production")
5453
+ attention.push({ id: "no-server-url" });
5454
+ return {
5455
+ system: {
5456
+ version: VERSION,
5457
+ database: config.db.name,
5458
+ storage: config.upload.storage?.name ?? "local",
5459
+ email: config.email ? config.email.name ?? "custom" : null,
5460
+ plugins: config.installedPlugins.map((p) => ({ ...p })),
5461
+ fieldTypes: config.fieldTypes.map((t) => t.name)
5462
+ },
5463
+ attention
5464
+ };
5465
+ }
5466
+
5122
5467
  // src/rest/filter-options.ts
5123
5468
  function fieldAt(fields, path) {
5124
5469
  let current = fields;
@@ -5505,6 +5850,10 @@ async function route(cms, ctx, method, segments, options) {
5505
5850
  return { body: new Response(null, { status: 304, headers }) };
5506
5851
  return { body: new Response(file.body, { headers }) };
5507
5852
  }
5853
+ if (second === "status" && third === void 0) {
5854
+ if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
5855
+ return { body: await adminStatus(cms) };
5856
+ }
5508
5857
  if (second === "scheduled" && third === void 0)
5509
5858
  return { body: await cms.upcomingJobs({ user: ctx.user, overrideAccess: false }) };
5510
5859
  const [, , collection3, id2, extra] = segments;
@@ -5545,6 +5894,15 @@ async function route(cms, ctx, method, segments, options) {
5545
5894
  return { body: await serveFile(cms, third, method === "HEAD") };
5546
5895
  }
5547
5896
  if (first === MEDIA && second === void 0 && method === "POST") {
5897
+ if ((ctx.request.headers.get("content-type") ?? "").toLowerCase().startsWith("application/json")) {
5898
+ const { url, ...data2 } = await readJson(ctx.request);
5899
+ if (typeof url !== "string" || url === "")
5900
+ throw new ValidationError(MEDIA, [{ field: "url", message: "is required" }]);
5901
+ return {
5902
+ status: 201,
5903
+ body: await cms.uploadFromURL(url, data2, { ...access, ...parseDepth(ctx.url) })
5904
+ };
5905
+ }
5548
5906
  const { file, data } = await readUpload(ctx.request, cms.config.upload.maxFileSize);
5549
5907
  return {
5550
5908
  status: 201,
@@ -5821,7 +6179,10 @@ async function serveFile(cms, key2, head) {
5821
6179
  async function readUpload(request, maxFileSize) {
5822
6180
  const type = request.headers.get("content-type") ?? "";
5823
6181
  if (!type.toLowerCase().startsWith("multipart/form-data")) {
5824
- throw new HttpError('Uploads must be multipart/form-data with a "file" field', 415);
6182
+ throw new HttpError(
6183
+ 'Uploads must be multipart/form-data with a "file" field, or JSON with a "url"',
6184
+ 415
6185
+ );
5825
6186
  }
5826
6187
  const declared = Number(request.headers.get("content-length") ?? 0);
5827
6188
  if (declared > maxFileSize + 64 * 1024)
@@ -6104,6 +6465,7 @@ export {
6104
6465
  SchemaError,
6105
6466
  TooManyRequestsError,
6106
6467
  UnauthorizedError,
6468
+ VERSION,
6107
6469
  ValidationError,
6108
6470
  WEBHOOK_EVENTS,
6109
6471
  anyone,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "description": "Embedded, code-first headless CMS for Nuxt and Next.js",
5
5
  "license": "MIT",
6
6
  "type": "module",