@easy-cms/core 0.26.0 → 0.28.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
@@ -879,6 +879,12 @@ declare class EasyCMS<C extends Config = Config> {
879
879
  data: Uint8Array;
880
880
  name: string;
881
881
  }, data?: Record<string, unknown>, options?: DepthOptions): Promise<MediaDocument>;
882
+ /**
883
+ * Downloads a file from an `http(s)` link and uploads it like `upload()`. Private network
884
+ * addresses are refused unless `upload.fromURL.allowPrivate`. Calls on behalf of a user (the
885
+ * REST API, `overrideAccess: false`) also need `upload.fromURL` and a host in its `allowedHosts`.
886
+ */
887
+ uploadFromURL(url: string, data?: Record<string, unknown>, options?: DepthOptions): Promise<MediaDocument>;
882
888
  /**
883
889
  * Creates an API key (needs `apiKeys: true`) and returns it once: only a hash is stored.
884
890
  * With access enforced the key belongs to `user`; trusted calls name the owner in `data.user`.
@@ -1526,7 +1532,7 @@ interface GlobalConfig {
1526
1532
  }
1527
1533
  type AdminLocale = 'en' | 'th';
1528
1534
  /** Icons a collection or global can show in the admin menu ([Lucide](https://lucide.dev) names). */
1529
- declare const ADMIN_ICONS: readonly ["file-text", "newspaper", "book-open", "notebook", "folder", "tag", "tags", "image", "images", "video", "music", "file", "users", "user", "building", "store", "shopping-bag", "shopping-cart", "package", "box", "calendar", "calendar-days", "map-pin", "globe", "house", "layout-grid", "layers", "star", "heart", "message-square", "mail", "phone", "briefcase", "graduation-cap", "utensils", "car", "settings", "sliders-horizontal", "palette", "megaphone", "bell", "link", "quote", "circle-help", "award", "ticket", "camera", "key"];
1535
+ declare const ADMIN_ICONS: readonly ["file-text", "newspaper", "book-open", "notebook", "folder", "tag", "tags", "image", "images", "video", "music", "file", "users", "user", "building", "store", "shopping-bag", "shopping-cart", "package", "box", "calendar", "calendar-days", "map-pin", "globe", "house", "layout-grid", "layers", "star", "heart", "message-square", "mail", "phone", "briefcase", "graduation-cap", "utensils", "car", "settings", "sliders-horizontal", "palette", "megaphone", "bell", "link", "quote", "circle-help", "award", "ticket", "camera", "key", "chart-column", "chart-line"];
1530
1536
  type AdminIcon = (typeof ADMIN_ICONS)[number];
1531
1537
  /** Your or your client's brand in the admin UI. */
1532
1538
  interface AdminBrand {
@@ -1537,6 +1543,34 @@ interface AdminBrand {
1537
1543
  /** Main color as `#rrggbb`; lighter and darker shades are derived from it. */
1538
1544
  readonly color?: string;
1539
1545
  }
1546
+ /** Who may see an admin page or dashboard widget. Default: every logged-in user. */
1547
+ type AdminViewAccess = (args: {
1548
+ readonly user: AuthUser;
1549
+ }) => boolean | Promise<boolean>;
1550
+ /** A page of its own in the admin, e.g. a plugin's report, at `<admin>/p/<path>`. */
1551
+ interface AdminPage {
1552
+ /** Its address under `<admin>/p/`: lowercase letters, digits and `-`, e.g. `forms-overview`. */
1553
+ readonly path: string;
1554
+ /** The page's body: a Web Component from an admin module. The admin draws the header. */
1555
+ readonly component: AdminComponent;
1556
+ /** Its title in the header and the menu. */
1557
+ readonly label: Label;
1558
+ /** Icon in the admin menu. Default `file-text`. */
1559
+ readonly icon?: AdminIcon;
1560
+ /** Listed under Content (default) or Settings in the menu, or not listed (`false`): reached by links. */
1561
+ readonly group?: 'content' | 'settings' | false;
1562
+ /** Who may open it. Pages a user may not open are left out of their admin. */
1563
+ readonly access?: AdminViewAccess;
1564
+ }
1565
+ /** A panel on the admin's dashboard, after the built-in ones. */
1566
+ interface DashboardWidget {
1567
+ /** A Web Component from an admin module. */
1568
+ readonly component: AdminComponent;
1569
+ /** `half` (default): the side column, beside drafts and recent edits; `full`: below both. */
1570
+ readonly width?: 'half' | 'full';
1571
+ /** Who sees it. */
1572
+ readonly access?: AdminViewAccess;
1573
+ }
1540
1574
  interface AdminConfig {
1541
1575
  /** Where the admin UI is served. Default `/admin`. */
1542
1576
  readonly path?: string;
@@ -1562,6 +1596,10 @@ interface AdminConfig {
1562
1596
  * module file. Remote URLs are not allowed.
1563
1597
  */
1564
1598
  readonly modules?: readonly string[];
1599
+ /** Pages of their own, e.g. from plugins, at `<admin>/p/<path>`. */
1600
+ readonly pages?: readonly AdminPage[];
1601
+ /** Panels on the dashboard, after the built-in ones. */
1602
+ readonly dashboard?: readonly DashboardWidget[];
1565
1603
  }
1566
1604
  interface ImageSize {
1567
1605
  /** Key in `media.sizes`, e.g. `thumbnail`. */
@@ -1571,6 +1609,22 @@ interface ImageSize {
1571
1609
  /** How to fit when both width and height are set. Default `cover`. */
1572
1610
  readonly fit?: 'cover' | 'contain' | 'inside';
1573
1611
  }
1612
+ /**
1613
+ * Uploads from a link: the server downloads the file (`cms.uploadFromURL`, `POST <api>/media`
1614
+ * with `{ url }`, "From a link" in the admin).
1615
+ */
1616
+ interface UploadFromURLConfig {
1617
+ /**
1618
+ * Hosts files may come from: `images.example.com`, `*.example.com` (its subdomains, not
1619
+ * `example.com` itself) or `*` for any public host. Checked at every redirect too.
1620
+ */
1621
+ readonly allowedHosts: readonly string[];
1622
+ /**
1623
+ * Also download from private network addresses (`10.x`, `192.168.x`, `localhost`…), e.g. an
1624
+ * intranet. Off by default: links could then reach services that are not meant to be public.
1625
+ */
1626
+ readonly allowPrivate?: boolean;
1627
+ }
1574
1628
  interface UploadConfig {
1575
1629
  /** Directory for uploaded files with the default local storage, relative to the project root. Default `uploads`. */
1576
1630
  readonly dir?: string;
@@ -1582,6 +1636,8 @@ interface UploadConfig {
1582
1636
  readonly storage?: StorageAdapter;
1583
1637
  /** Resized copies generated for images when `sharp` is installed. */
1584
1638
  readonly imageSizes?: readonly ImageSize[];
1639
+ /** Lets users upload from a link. Off by default. */
1640
+ readonly fromURL?: UploadFromURLConfig;
1585
1641
  }
1586
1642
  interface RoutesConfig {
1587
1643
  /** Where the REST API is served. Default `/api/cms`. */
@@ -1768,7 +1824,7 @@ interface ResolvedConfig extends Omit<Config, 'cors' | 'localization' | 'routes'
1768
1824
  readonly localization: Required<LocalizationConfig> | null;
1769
1825
  readonly routes: Required<RoutesConfig>;
1770
1826
  readonly admin: Required<AdminConfig>;
1771
- readonly upload: Required<Omit<UploadConfig, 'storage'>> & Pick<UploadConfig, 'storage'>;
1827
+ readonly upload: Required<Omit<UploadConfig, 'storage' | 'fromURL'>> & Pick<UploadConfig, 'storage' | 'fromURL'>;
1772
1828
  readonly auth: Required<AuthConfig>;
1773
1829
  readonly collections: readonly CollectionConfig[];
1774
1830
  readonly globals: readonly GlobalConfig[];
@@ -2069,6 +2125,20 @@ interface AdminGlobal {
2069
2125
  update: boolean;
2070
2126
  };
2071
2127
  }
2128
+ /** A page of its own in the admin (`admin.pages`) that this user may open. */
2129
+ interface AdminPageRef {
2130
+ path: string;
2131
+ label: Label;
2132
+ icon?: string;
2133
+ /** Where it is listed in the menu; `false`: not listed. */
2134
+ group: 'content' | 'settings' | false;
2135
+ component: AdminComponentRef;
2136
+ }
2137
+ /** A dashboard panel (`admin.dashboard`) this user may see. */
2138
+ interface AdminWidgetRef {
2139
+ component: AdminComponentRef;
2140
+ width: 'half' | 'full';
2141
+ }
2072
2142
  interface AdminSchema {
2073
2143
  locale: AdminLocale;
2074
2144
  /** Admins can email users links to set their password (needs `email` and `serverURL`). */
@@ -2084,6 +2154,12 @@ interface AdminSchema {
2084
2154
  globals: AdminGlobal[];
2085
2155
  /** URLs under the API of the admin modules to load (`admin.modules`). */
2086
2156
  modules: string[];
2157
+ /** Files can be uploaded from links (`upload.fromURL`). */
2158
+ uploadFromURL: boolean;
2159
+ /** Pages this user may open (`admin.pages`). */
2160
+ pages: AdminPageRef[];
2161
+ /** Dashboard panels this user may see (`admin.dashboard`). */
2162
+ dashboard: AdminWidgetRef[];
2087
2163
  }
2088
2164
 
2089
2165
  declare const SESSION_COOKIE = "ecms-session";
@@ -2121,4 +2197,4 @@ declare const BUILTIN_COLLECTIONS: readonly ["users", "media"];
2121
2197
  /** Checks a config and returns every problem found. An empty array means it is valid. */
2122
2198
  declare function validateConfig(config: Config): ConfigIssue[];
2123
2199
 
2124
- export { ADMIN_ICONS, API_KEYS, API_KEY_GLOBAL_OPERATIONS, API_KEY_OPERATIONS, type Access, type AccessArgs, type AccessOptions, type AdminBrand, type AdminCollection, type AdminComponent, type AdminComponentRef, type AdminConfig, type AdminField, type AdminGlobal, type AdminIcon, type AdminLocale, type AdminSchema, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ApiKeyContext, type ApiKeyGlobalOperation, type ApiKeyOperation, type ApiKeyPermissions, type ArrayField, Auth, type AuthConfig, type AuthUser, BUILTIN_COLLECTIONS, type BeforeChangeHook, type BeforeDeleteHook, type BeforeValidateHook, type Block, type BlocksField, type BooleanField, type BuiltinApiKeysCollection, type BuiltinMediaCollection, type BuiltinUsersCollection, type BundleHints, CONFIG_FILE_NAMES, CSRF_COOKIE, CSRF_HEADER, type CliCommand, type CollectionAccess, type CollectionAdmin, type CollectionConfig, type CollectionDocument, type CollectionHooks, type CollectionListAdmin, type CollectionSlug, type Config, ConfigError, type ConfigIssue, type ContainerAdmin, type CopyProgress, type CopyResult, type CreateEasyCMSOptions, type CreateInput, type CreateMigrationResult, 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 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 };
2200
+ 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, type UploadFromURLConfig, 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
  }
@@ -1138,7 +1138,9 @@ var ADMIN_ICONS = [
1138
1138
  "award",
1139
1139
  "ticket",
1140
1140
  "camera",
1141
- "key"
1141
+ "key",
1142
+ "chart-column",
1143
+ "chart-line"
1142
1144
  ];
1143
1145
  function definePlugin(plugin) {
1144
1146
  return plugin;
@@ -1742,7 +1744,7 @@ function applyFieldTypes(config) {
1742
1744
  const problem = type.checkOptions?.(own);
1743
1745
  if (problem) issues.push({ path: here, message: problem });
1744
1746
  const ownValidate = own.validate;
1745
- const check = type.validate;
1747
+ const check2 = type.validate;
1746
1748
  const admin = own.admin ?? {};
1747
1749
  const input = admin.component ?? component(type.admin?.component, own, type.admin?.props);
1748
1750
  const cell = admin.cell ?? component(type.admin?.cell, own, type.admin?.props);
@@ -1750,11 +1752,11 @@ function applyFieldTypes(config) {
1750
1752
  ...own,
1751
1753
  type: type.base,
1752
1754
  customType: type.name,
1753
- ...check || ownValidate ? {
1755
+ ...check2 || ownValidate ? {
1754
1756
  validate: async (value, ctx) => {
1755
1757
  const empty = value === null || value === void 0 || value === "";
1756
- if (check && !empty) {
1757
- const result = await check(value, { ...ctx, field: own });
1758
+ if (check2 && !empty) {
1759
+ const result = await check2(value, { ...ctx, field: own });
1758
1760
  if (result !== true) return result;
1759
1761
  }
1760
1762
  return ownValidate ? ownValidate(value, ctx) : true;
@@ -1994,6 +1996,7 @@ function validateConfig(config) {
1994
1996
  add("db", "is required", "pass a database adapter, e.g. db: sqlite({ url: 'file:./cms.db' })");
1995
1997
  }
1996
1998
  validateAdmin(config, add);
1999
+ validateAdminViews(config, add);
1997
2000
  validateUpload(config, add);
1998
2001
  validateAuth(config, add);
1999
2002
  validateCors(config.cors, add);
@@ -2114,6 +2117,83 @@ function validateAdmin(config, add) {
2114
2117
  add("admin.brand.color", `must be a hex color like "#0f766e" (got ${JSON.stringify(color)})`);
2115
2118
  }
2116
2119
  }
2120
+ var PAGE_PATH = /^[a-z0-9]+(-[a-z0-9]+)*$/;
2121
+ function validateAdminViews(config, add) {
2122
+ const access = (value, path) => {
2123
+ if (value !== void 0 && typeof value !== "function")
2124
+ add(
2125
+ `${path}.access`,
2126
+ "must be a function",
2127
+ 'e.g. access: ({ user }) => user.role === "admin"'
2128
+ );
2129
+ };
2130
+ const pages2 = config.admin?.pages;
2131
+ if (pages2 !== void 0) {
2132
+ if (!Array.isArray(pages2)) {
2133
+ add(
2134
+ "admin.pages",
2135
+ "must be an array",
2136
+ "e.g. pages: [{ path: 'stats', label: 'Stats', component: 'ecms-stats' }]"
2137
+ );
2138
+ } else {
2139
+ const seen = /* @__PURE__ */ new Set();
2140
+ pages2.forEach((page, i) => {
2141
+ const path = `admin.pages[${i}]`;
2142
+ if (typeof page !== "object" || page === null) {
2143
+ add(path, "must be an object");
2144
+ return;
2145
+ }
2146
+ const p = page;
2147
+ if (typeof p.path !== "string" || !PAGE_PATH.test(p.path)) {
2148
+ add(
2149
+ `${path}.path`,
2150
+ `must be lowercase letters, digits and "-" (got ${JSON.stringify(p.path)})`,
2151
+ "e.g. path: 'forms-overview'"
2152
+ );
2153
+ } else if (seen.has(p.path)) {
2154
+ add(`${path}.path`, `"${p.path}" is used by another page`);
2155
+ } else {
2156
+ seen.add(p.path);
2157
+ }
2158
+ validateComponent(p.component, `${path}.component`, add);
2159
+ const label = p.label;
2160
+ if (!(typeof label === "string" && label.trim() !== "") && !(typeof label === "object" && label !== null && !Array.isArray(label)))
2161
+ add(
2162
+ `${path}.label`,
2163
+ "must be a string or { en, th }",
2164
+ "e.g. label: { en: 'Stats', th: '\u0E2A\u0E16\u0E34\u0E15\u0E34' }"
2165
+ );
2166
+ validateIcon(p, path, add);
2167
+ if (p.group !== void 0 && p.group !== "content" && p.group !== "settings" && p.group !== false)
2168
+ add(`${path}.group`, 'must be "content", "settings" or false');
2169
+ access(p.access, path);
2170
+ });
2171
+ }
2172
+ }
2173
+ const widgets2 = config.admin?.dashboard;
2174
+ if (widgets2 !== void 0) {
2175
+ if (!Array.isArray(widgets2)) {
2176
+ add(
2177
+ "admin.dashboard",
2178
+ "must be an array",
2179
+ "e.g. dashboard: [{ component: 'ecms-stats-widget' }]"
2180
+ );
2181
+ } else {
2182
+ widgets2.forEach((widget, i) => {
2183
+ const path = `admin.dashboard[${i}]`;
2184
+ if (typeof widget !== "object" || widget === null) {
2185
+ add(path, "must be an object");
2186
+ return;
2187
+ }
2188
+ const w = widget;
2189
+ validateComponent(w.component, `${path}.component`, add);
2190
+ if (w.width !== void 0 && w.width !== "half" && w.width !== "full")
2191
+ add(`${path}.width`, 'must be "half" or "full"');
2192
+ access(w.access, path);
2193
+ });
2194
+ }
2195
+ }
2196
+ }
2117
2197
  function validateIcon(container, path, add) {
2118
2198
  const icon = container.icon;
2119
2199
  if (icon === void 0 || ADMIN_ICONS.includes(icon)) return;
@@ -2308,7 +2388,32 @@ function validateUpload(config, add) {
2308
2388
  if (upload.dir !== void 0 && (typeof upload.dir !== "string" || upload.dir.length === 0)) {
2309
2389
  add("upload.dir", "must be a non-empty string");
2310
2390
  }
2391
+ const fromURL = upload.fromURL;
2392
+ if (fromURL === void 0) return;
2393
+ if (typeof fromURL !== "object" || fromURL === null) {
2394
+ add("upload.fromURL", "must be an object", "e.g. fromURL: { allowedHosts: ['*'] }");
2395
+ return;
2396
+ }
2397
+ const { allowedHosts, allowPrivate } = fromURL;
2398
+ if (!Array.isArray(allowedHosts) || allowedHosts.length === 0) {
2399
+ add(
2400
+ "upload.fromURL.allowedHosts",
2401
+ "must be a non-empty array of host names",
2402
+ "e.g. allowedHosts: ['images.example.com', '*.cdn.example.com'] or ['*'] for any public host"
2403
+ );
2404
+ } else {
2405
+ allowedHosts.forEach((host, i) => {
2406
+ if (typeof host !== "string" || !HOST_PATTERN.test(host))
2407
+ add(
2408
+ `upload.fromURL.allowedHosts[${i}]`,
2409
+ `must be a host name like "images.example.com", "*.example.com" or "*" (got ${JSON.stringify(host)})`
2410
+ );
2411
+ });
2412
+ }
2413
+ if (allowPrivate !== void 0 && typeof allowPrivate !== "boolean")
2414
+ add("upload.fromURL.allowPrivate", "must be true or false");
2311
2415
  }
2416
+ var HOST_PATTERN = /^(\*|(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*)$/i;
2312
2417
  function asArray(value, path, add) {
2313
2418
  if (value === void 0) return [];
2314
2419
  if (!Array.isArray(value)) {
@@ -2751,14 +2856,17 @@ async function resolveConfig(input) {
2751
2856
  brand: config.admin?.brand ?? {},
2752
2857
  siteUrl: config.admin?.siteUrl ?? "",
2753
2858
  menu: config.admin?.menu ?? [],
2754
- modules: config.admin?.modules ?? []
2859
+ modules: config.admin?.modules ?? [],
2860
+ pages: config.admin?.pages ?? [],
2861
+ dashboard: config.admin?.dashboard ?? []
2755
2862
  },
2756
2863
  upload: {
2757
2864
  dir: config.upload?.dir ?? "uploads",
2758
2865
  maxFileSize: config.upload?.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
2759
2866
  mimeTypes: config.upload?.mimeTypes ?? ["image/*", "application/pdf"],
2760
2867
  imageSizes: config.upload?.imageSizes ?? [],
2761
- ...config.upload?.storage ? { storage: config.upload.storage } : {}
2868
+ ...config.upload?.storage ? { storage: config.upload.storage } : {},
2869
+ ...config.upload?.fromURL ? { fromURL: config.upload.fromURL } : {}
2762
2870
  },
2763
2871
  auth: {
2764
2872
  roles: config.auth?.roles ?? DEFAULT_ROLES,
@@ -3113,6 +3221,212 @@ function collectSlots(fields, data, bySlug, slots) {
3113
3221
  }
3114
3222
  }
3115
3223
 
3224
+ // src/remote-file.ts
3225
+ import { lookup as dnsLookup } from "dns";
3226
+ import { request as httpRequest } from "http";
3227
+ import { request as httpsRequest } from "https";
3228
+ import { BlockList, isIP } from "net";
3229
+ var RemoteFileError = class extends Error {
3230
+ /** `413` when the file is too large, otherwise `400`. */
3231
+ status;
3232
+ constructor(message, status = 400) {
3233
+ super(message);
3234
+ this.name = "RemoteFileError";
3235
+ this.status = status;
3236
+ }
3237
+ };
3238
+ var PRIVATE = new BlockList();
3239
+ for (const [net, prefix] of [
3240
+ ["0.0.0.0", 8],
3241
+ ["10.0.0.0", 8],
3242
+ ["100.64.0.0", 10],
3243
+ ["127.0.0.0", 8],
3244
+ ["169.254.0.0", 16],
3245
+ ["172.16.0.0", 12],
3246
+ ["192.0.0.0", 24],
3247
+ ["192.0.2.0", 24],
3248
+ ["192.168.0.0", 16],
3249
+ ["198.18.0.0", 15],
3250
+ ["198.51.100.0", 24],
3251
+ ["203.0.113.0", 24],
3252
+ ["224.0.0.0", 4],
3253
+ ["240.0.0.0", 4]
3254
+ ])
3255
+ PRIVATE.addSubnet(net, prefix, "ipv4");
3256
+ for (const [net, prefix] of [
3257
+ ["::", 128],
3258
+ ["::1", 128],
3259
+ ["64:ff9b:1::", 48],
3260
+ ["100::", 64],
3261
+ ["2001:db8::", 32],
3262
+ ["fc00::", 7],
3263
+ ["fe80::", 10],
3264
+ ["ff00::", 8]
3265
+ ])
3266
+ PRIVATE.addSubnet(net, prefix, "ipv6");
3267
+ function isPrivateAddress(address) {
3268
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address);
3269
+ if (mapped?.[1]) return PRIVATE.check(mapped[1], "ipv4");
3270
+ const family = isIP(address);
3271
+ if (family === 4) return PRIVATE.check(address, "ipv4");
3272
+ if (family === 6) return PRIVATE.check(address, "ipv6");
3273
+ return true;
3274
+ }
3275
+ function hostAllowed(host, allowedHosts) {
3276
+ const name = host.toLowerCase().replace(/\.$/, "");
3277
+ return allowedHosts.some((rule) => {
3278
+ const pattern = rule.toLowerCase();
3279
+ if (pattern === "*") return true;
3280
+ if (pattern.startsWith("*.")) return name.endsWith(pattern.slice(1));
3281
+ return name === pattern;
3282
+ });
3283
+ }
3284
+ function guardedLookup(allowPrivate) {
3285
+ return (hostname, options, callback) => {
3286
+ dnsLookup(hostname, { ...options, all: true }, (error, addresses) => {
3287
+ if (error) return callback(error, "", 4);
3288
+ const usable = allowPrivate ? addresses : addresses.filter((a) => !isPrivateAddress(a.address));
3289
+ if (usable.length === 0) {
3290
+ const blocked = new Error(`${hostname} points to a private network address`);
3291
+ blocked.code = "ECMS_PRIVATE";
3292
+ return callback(blocked, "", 4);
3293
+ }
3294
+ if (options.all) return callback(null, usable, 4);
3295
+ const first = usable[0];
3296
+ callback(null, first.address, first.family);
3297
+ });
3298
+ };
3299
+ }
3300
+ function remoteFileName(url, disposition) {
3301
+ if (disposition) {
3302
+ const star = /filename\*\s*=\s*(?:UTF-8|utf-8)''([^;]+)/.exec(disposition);
3303
+ if (star?.[1]) {
3304
+ try {
3305
+ return clean(decodeURIComponent(star[1].trim()));
3306
+ } catch {
3307
+ }
3308
+ }
3309
+ const plain = /filename\s*=\s*"?([^";]+)"?/.exec(disposition);
3310
+ if (plain?.[1]) return clean(plain[1].trim());
3311
+ }
3312
+ const last = url.pathname.split("/").filter(Boolean).pop();
3313
+ if (last) {
3314
+ try {
3315
+ return clean(decodeURIComponent(last));
3316
+ } catch {
3317
+ return clean(last);
3318
+ }
3319
+ }
3320
+ return "download";
3321
+ }
3322
+ var clean = (name) => name.replace(/[\\/]/g, "_").slice(0, 200) || "download";
3323
+ async function fetchRemoteFile(link, options) {
3324
+ let url;
3325
+ try {
3326
+ url = new URL(link);
3327
+ } catch {
3328
+ throw new RemoteFileError("must be an http(s) link");
3329
+ }
3330
+ const deadline = Date.now() + (options.timeout ?? 15e3);
3331
+ const maxRedirects = options.maxRedirects ?? 3;
3332
+ for (let redirects = 0; ; redirects++) {
3333
+ check(url, options);
3334
+ const response = await get(url, options, deadline);
3335
+ const status = response.statusCode ?? 0;
3336
+ if (status >= 300 && status < 400 && response.headers.location) {
3337
+ response.resume();
3338
+ if (redirects >= maxRedirects) throw new RemoteFileError("redirects too many times");
3339
+ url = new URL(response.headers.location, url);
3340
+ continue;
3341
+ }
3342
+ if (status < 200 || status >= 300) {
3343
+ response.resume();
3344
+ throw new RemoteFileError(`could not be downloaded (HTTP ${status})`);
3345
+ }
3346
+ const data = await read(response, options.maxBytes, deadline);
3347
+ const contentType = response.headers["content-type"];
3348
+ return {
3349
+ data,
3350
+ name: remoteFileName(url, response.headers["content-disposition"]),
3351
+ ...contentType ? { contentType } : {}
3352
+ };
3353
+ }
3354
+ }
3355
+ function check(url, options) {
3356
+ if (url.protocol !== "http:" && url.protocol !== "https:")
3357
+ throw new RemoteFileError("must be an http(s) link");
3358
+ if (url.username || url.password) throw new RemoteFileError("must not contain a password");
3359
+ const host = url.hostname.replace(/^\[|\]$/g, "");
3360
+ if (options.allowedHosts && !hostAllowed(host, options.allowedHosts))
3361
+ throw new RemoteFileError(`${host} is not an allowed host`);
3362
+ if (!options.allowPrivate && isIP(host) && isPrivateAddress(host))
3363
+ throw new RemoteFileError(`${host} is a private network address`);
3364
+ }
3365
+ function get(url, options, deadline) {
3366
+ const request = url.protocol === "https:" ? httpsRequest : httpRequest;
3367
+ return new Promise((resolve3, reject) => {
3368
+ const req = request(url, {
3369
+ method: "GET",
3370
+ lookup: guardedLookup(options.allowPrivate === true),
3371
+ headers: {
3372
+ "user-agent": options.userAgent ?? "EasyCMS",
3373
+ accept: "*/*"
3374
+ }
3375
+ });
3376
+ const timer = setTimeout(
3377
+ () => req.destroy(new RemoteFileError("took too long to download")),
3378
+ Math.max(0, deadline - Date.now())
3379
+ );
3380
+ req.on("response", (response) => {
3381
+ clearTimeout(timer);
3382
+ resolve3(response);
3383
+ });
3384
+ req.on("error", (error) => {
3385
+ clearTimeout(timer);
3386
+ if (error instanceof RemoteFileError) reject(error);
3387
+ else if (error.code === "ECMS_PRIVATE")
3388
+ reject(new RemoteFileError(`${url.hostname} points to a private network address`));
3389
+ else if (error.code === "ENOTFOUND" || error.code === "EAI_AGAIN")
3390
+ reject(new RemoteFileError(`${url.hostname} was not found`));
3391
+ else reject(new RemoteFileError("could not be downloaded"));
3392
+ });
3393
+ req.end();
3394
+ });
3395
+ }
3396
+ function read(response, maxBytes, deadline) {
3397
+ const declared = Number(response.headers["content-length"]);
3398
+ if (Number.isFinite(declared) && declared > maxBytes) {
3399
+ response.destroy();
3400
+ return Promise.reject(new RemoteFileError(`is larger than ${maxBytes} bytes`, 413));
3401
+ }
3402
+ return new Promise((resolve3, reject) => {
3403
+ const chunks = [];
3404
+ let size = 0;
3405
+ const timer = setTimeout(
3406
+ () => response.destroy(new RemoteFileError("took too long to download")),
3407
+ Math.max(0, deadline - Date.now())
3408
+ );
3409
+ response.on("data", (chunk) => {
3410
+ size += chunk.byteLength;
3411
+ if (size > maxBytes) {
3412
+ response.destroy(new RemoteFileError(`is larger than ${maxBytes} bytes`, 413));
3413
+ return;
3414
+ }
3415
+ chunks.push(chunk);
3416
+ });
3417
+ response.on("end", () => {
3418
+ clearTimeout(timer);
3419
+ resolve3(new Uint8Array(Buffer.concat(chunks)));
3420
+ });
3421
+ response.on("error", (error) => {
3422
+ clearTimeout(timer);
3423
+ reject(
3424
+ error instanceof RemoteFileError ? error : new RemoteFileError("could not be downloaded")
3425
+ );
3426
+ });
3427
+ });
3428
+ }
3429
+
3116
3430
  // src/storage.ts
3117
3431
  import { mkdir, readFile, rm, stat, writeFile } from "fs/promises";
3118
3432
  import { dirname, isAbsolute, join, normalize, resolve as resolve2, sep } from "path";
@@ -3475,6 +3789,39 @@ var EasyCMS = class {
3475
3789
  throw error;
3476
3790
  }
3477
3791
  }
3792
+ /**
3793
+ * Downloads a file from an `http(s)` link and uploads it like `upload()`. Private network
3794
+ * addresses are refused unless `upload.fromURL.allowPrivate`. Calls on behalf of a user (the
3795
+ * REST API, `overrideAccess: false`) also need `upload.fromURL` and a host in its `allowedHosts`.
3796
+ */
3797
+ async uploadFromURL(url, data = {}, options = {}) {
3798
+ const config = this.collection(MEDIA);
3799
+ const guard = guardOf(options);
3800
+ const fromURL = this.config.upload.fromURL;
3801
+ if (guard.enforce) {
3802
+ this.checkKey(guard, { collection: MEDIA }, "create");
3803
+ const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data });
3804
+ if (allowed2 !== true) throw deny(guard.user);
3805
+ if (!fromURL)
3806
+ throw new ValidationError(MEDIA, [
3807
+ { field: "url", message: "uploads from links are off (upload.fromURL)" }
3808
+ ]);
3809
+ }
3810
+ let file;
3811
+ try {
3812
+ file = await fetchRemoteFile(url, {
3813
+ allowedHosts: guard.enforce ? fromURL?.allowedHosts : void 0,
3814
+ allowPrivate: fromURL?.allowPrivate === true,
3815
+ maxBytes: this.config.upload.maxFileSize,
3816
+ userAgent: "EasyCMS (+https://github.com/maritonx/easy-cms)"
3817
+ });
3818
+ } catch (error) {
3819
+ if (!(error instanceof RemoteFileError)) throw error;
3820
+ if (error.status === 413) throw new PayloadTooLargeError(`File ${error.message}`);
3821
+ throw new ValidationError(MEDIA, [{ field: "url", message: error.message }]);
3822
+ }
3823
+ return this.upload(file, data, options);
3824
+ }
3478
3825
  /**
3479
3826
  * Creates an API key (needs `apiKeys: true`) and returns it once: only a hash is stored.
3480
3827
  * With access enforced the key belongs to `user`; trusted calls name the owner in `data.user`.
@@ -3684,7 +4031,7 @@ var EasyCMS = class {
3684
4031
  /** Coerces preview input like a save would, keeping values that would fail validation. */
3685
4032
  async previewDoc(config, data, system2, options = {}) {
3686
4033
  const locale = this.localeOf(options);
3687
- const { data: clean } = await validateFields(config.fields, data, {
4034
+ const { data: clean2 } = await validateFields(config.fields, data, {
3688
4035
  operation: "update",
3689
4036
  root: data,
3690
4037
  skipRequired: true,
@@ -3693,7 +4040,7 @@ var EasyCMS = class {
3693
4040
  });
3694
4041
  return {
3695
4042
  ...data,
3696
- ...clean,
4043
+ ...clean2,
3697
4044
  ...config.drafts ? { status: data.status } : {},
3698
4045
  ...system2,
3699
4046
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -3982,14 +4329,14 @@ var EasyCMS = class {
3982
4329
  const parsed = parseId(id);
3983
4330
  if (parsed === void 0) return { update: false, delete: false };
3984
4331
  const guard = { enforce: true, user };
3985
- const check = (operation) => this.checkDocumentAccess(config, operation, guard, parsed, void 0).then(
4332
+ const check2 = (operation) => this.checkDocumentAccess(config, operation, guard, parsed, void 0).then(
3986
4333
  () => true,
3987
4334
  (error) => {
3988
4335
  if (error instanceof ForbiddenError || error instanceof UnauthorizedError) return false;
3989
4336
  throw error;
3990
4337
  }
3991
4338
  );
3992
- return { update: await check("update"), delete: await check("delete") };
4339
+ return { update: await check2("update"), delete: await check2("delete") };
3993
4340
  }
3994
4341
  /** Closes the database connection. */
3995
4342
  /**
@@ -4363,7 +4710,7 @@ var EasyCMS = class {
4363
4710
  }
4364
4711
  /** Runs afterRead hooks, populates relationships and removes what the caller may not see. */
4365
4712
  async output(config, docs, guard, options) {
4366
- const read = this.fieldChecker("read", guard, void 0, void 0);
4713
+ const read2 = this.fieldChecker("read", guard, void 0, void 0);
4367
4714
  const finish = async (target, doc) => {
4368
4715
  const hooked = await this.transform(
4369
4716
  target.hooks?.afterRead,
@@ -4371,7 +4718,7 @@ var EasyCMS = class {
4371
4718
  this.hookArgs(target, guard),
4372
4719
  doc
4373
4720
  );
4374
- return await stripFields(target.fields, hooked, read);
4721
+ return await stripFields(target.fields, hooked, read2);
4375
4722
  };
4376
4723
  const load = async (target, ids) => {
4377
4724
  const where = await this.readWhere(target, guard, { id: { in: [...ids] } }).catch((error) => {
@@ -4614,24 +4961,24 @@ var EasyCMS = class {
4614
4961
  ...locale !== void 0 && locale !== ALL_LOCALES ? { locale } : {}
4615
4962
  });
4616
4963
  const errors = [...result.errors];
4617
- const clean = { ...result.data };
4964
+ const clean2 = { ...result.data };
4618
4965
  if (config.drafts) {
4619
4966
  const status = data.status ?? "draft";
4620
4967
  if (status !== "draft" && status !== "published") {
4621
4968
  errors.push({ field: "status", message: 'must be "draft" or "published"' });
4622
4969
  }
4623
- clean.status = status;
4970
+ clean2.status = status;
4624
4971
  }
4625
4972
  const isCollection = this.config.collections.includes(config);
4626
4973
  if (isCollection && errors.length === 0) {
4627
- await this.makeSlugsUnique(config, clean, selfId);
4628
- errors.push(...await this.checkUnique(config, clean, selfId));
4974
+ await this.makeSlugsUnique(config, clean2, selfId);
4975
+ errors.push(...await this.checkUnique(config, clean2, selfId));
4629
4976
  }
4630
4977
  errors.push(...await this.checkReferences(result.references));
4631
4978
  if (errors.length === 0)
4632
4979
  errors.push(...await this.checkFilterOptions(result.references, selfId, user));
4633
4980
  if (errors.length > 0) throw new ValidationError(config.slug, errors);
4634
- return clean;
4981
+ return clean2;
4635
4982
  }
4636
4983
  /** Refuses changes that would leave no active admin (and lock everyone out). */
4637
4984
  async guardLastAdmin(id, before, after) {
@@ -5001,9 +5348,42 @@ async function adminSchema(cms, user, origin) {
5001
5348
  localization: localization ? { locales: [...localization.locales], defaultLocale: localization.defaultLocale } : null,
5002
5349
  collections: await Promise.all(collections.map((c) => collection(c, user, localized))),
5003
5350
  globals: await Promise.all(cms.config.globals.map((g) => global(g, user, localized))),
5004
- modules: adminModuleUrls(cms)
5351
+ modules: adminModuleUrls(cms),
5352
+ uploadFromURL: cms.config.upload.fromURL !== void 0,
5353
+ pages: await pages(cms, user),
5354
+ dashboard: await widgets(cms, user)
5005
5355
  };
5006
5356
  }
5357
+ async function shown(access, user) {
5358
+ if (!access) return true;
5359
+ try {
5360
+ return await access({ user }) === true;
5361
+ } catch {
5362
+ return false;
5363
+ }
5364
+ }
5365
+ async function pages(cms, user) {
5366
+ const out = [];
5367
+ for (const page of cms.config.admin.pages) {
5368
+ if (!await shown(page.access, user)) continue;
5369
+ out.push({
5370
+ path: page.path,
5371
+ label: page.label,
5372
+ ...page.icon ? { icon: page.icon } : {},
5373
+ group: page.group ?? "content",
5374
+ component: componentRef(page.component)
5375
+ });
5376
+ }
5377
+ return out;
5378
+ }
5379
+ async function widgets(cms, user) {
5380
+ const out = [];
5381
+ for (const widget of cms.config.admin.dashboard) {
5382
+ if (!await shown(widget.access, user)) continue;
5383
+ out.push({ component: componentRef(widget.component), width: widget.width ?? "half" });
5384
+ }
5385
+ return out;
5386
+ }
5007
5387
 
5008
5388
  // src/rest/filter-options.ts
5009
5389
  function fieldAt(fields, path) {
@@ -5431,6 +5811,15 @@ async function route(cms, ctx, method, segments, options) {
5431
5811
  return { body: await serveFile(cms, third, method === "HEAD") };
5432
5812
  }
5433
5813
  if (first === MEDIA && second === void 0 && method === "POST") {
5814
+ if ((ctx.request.headers.get("content-type") ?? "").toLowerCase().startsWith("application/json")) {
5815
+ const { url, ...data2 } = await readJson(ctx.request);
5816
+ if (typeof url !== "string" || url === "")
5817
+ throw new ValidationError(MEDIA, [{ field: "url", message: "is required" }]);
5818
+ return {
5819
+ status: 201,
5820
+ body: await cms.uploadFromURL(url, data2, { ...access, ...parseDepth(ctx.url) })
5821
+ };
5822
+ }
5434
5823
  const { file, data } = await readUpload(ctx.request, cms.config.upload.maxFileSize);
5435
5824
  return {
5436
5825
  status: 201,
@@ -5707,7 +6096,10 @@ async function serveFile(cms, key2, head) {
5707
6096
  async function readUpload(request, maxFileSize) {
5708
6097
  const type = request.headers.get("content-type") ?? "";
5709
6098
  if (!type.toLowerCase().startsWith("multipart/form-data")) {
5710
- throw new HttpError('Uploads must be multipart/form-data with a "file" field', 415);
6099
+ throw new HttpError(
6100
+ 'Uploads must be multipart/form-data with a "file" field, or JSON with a "url"',
6101
+ 415
6102
+ );
5711
6103
  }
5712
6104
  const declared = Number(request.headers.get("content-length") ?? 0);
5713
6105
  if (declared > maxFileSize + 64 * 1024)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
4
4
  "description": "Embedded, code-first headless CMS for Nuxt and Next.js",
5
5
  "license": "MIT",
6
6
  "type": "module",