@easy-cms/core 0.27.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`.
@@ -1560,7 +1566,7 @@ interface AdminPage {
1560
1566
  interface DashboardWidget {
1561
1567
  /** A Web Component from an admin module. */
1562
1568
  readonly component: AdminComponent;
1563
- /** Half the dashboard's width (default) or all of it. Full width on phones. */
1569
+ /** `half` (default): the side column, beside drafts and recent edits; `full`: below both. */
1564
1570
  readonly width?: 'half' | 'full';
1565
1571
  /** Who sees it. */
1566
1572
  readonly access?: AdminViewAccess;
@@ -1603,6 +1609,22 @@ interface ImageSize {
1603
1609
  /** How to fit when both width and height are set. Default `cover`. */
1604
1610
  readonly fit?: 'cover' | 'contain' | 'inside';
1605
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
+ }
1606
1628
  interface UploadConfig {
1607
1629
  /** Directory for uploaded files with the default local storage, relative to the project root. Default `uploads`. */
1608
1630
  readonly dir?: string;
@@ -1614,6 +1636,8 @@ interface UploadConfig {
1614
1636
  readonly storage?: StorageAdapter;
1615
1637
  /** Resized copies generated for images when `sharp` is installed. */
1616
1638
  readonly imageSizes?: readonly ImageSize[];
1639
+ /** Lets users upload from a link. Off by default. */
1640
+ readonly fromURL?: UploadFromURLConfig;
1617
1641
  }
1618
1642
  interface RoutesConfig {
1619
1643
  /** Where the REST API is served. Default `/api/cms`. */
@@ -1800,7 +1824,7 @@ interface ResolvedConfig extends Omit<Config, 'cors' | 'localization' | 'routes'
1800
1824
  readonly localization: Required<LocalizationConfig> | null;
1801
1825
  readonly routes: Required<RoutesConfig>;
1802
1826
  readonly admin: Required<AdminConfig>;
1803
- readonly upload: Required<Omit<UploadConfig, 'storage'>> & Pick<UploadConfig, 'storage'>;
1827
+ readonly upload: Required<Omit<UploadConfig, 'storage' | 'fromURL'>> & Pick<UploadConfig, 'storage' | 'fromURL'>;
1804
1828
  readonly auth: Required<AuthConfig>;
1805
1829
  readonly collections: readonly CollectionConfig[];
1806
1830
  readonly globals: readonly GlobalConfig[];
@@ -2130,6 +2154,8 @@ interface AdminSchema {
2130
2154
  globals: AdminGlobal[];
2131
2155
  /** URLs under the API of the admin modules to load (`admin.modules`). */
2132
2156
  modules: string[];
2157
+ /** Files can be uploaded from links (`upload.fromURL`). */
2158
+ uploadFromURL: boolean;
2133
2159
  /** Pages this user may open (`admin.pages`). */
2134
2160
  pages: AdminPageRef[];
2135
2161
  /** Dashboard panels this user may see (`admin.dashboard`). */
@@ -2171,4 +2197,4 @@ declare const BUILTIN_COLLECTIONS: readonly ["users", "media"];
2171
2197
  /** Checks a config and returns every problem found. An empty array means it is valid. */
2172
2198
  declare function validateConfig(config: Config): ConfigIssue[];
2173
2199
 
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 };
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
  }
@@ -1744,7 +1744,7 @@ function applyFieldTypes(config) {
1744
1744
  const problem = type.checkOptions?.(own);
1745
1745
  if (problem) issues.push({ path: here, message: problem });
1746
1746
  const ownValidate = own.validate;
1747
- const check = type.validate;
1747
+ const check2 = type.validate;
1748
1748
  const admin = own.admin ?? {};
1749
1749
  const input = admin.component ?? component(type.admin?.component, own, type.admin?.props);
1750
1750
  const cell = admin.cell ?? component(type.admin?.cell, own, type.admin?.props);
@@ -1752,11 +1752,11 @@ function applyFieldTypes(config) {
1752
1752
  ...own,
1753
1753
  type: type.base,
1754
1754
  customType: type.name,
1755
- ...check || ownValidate ? {
1755
+ ...check2 || ownValidate ? {
1756
1756
  validate: async (value, ctx) => {
1757
1757
  const empty = value === null || value === void 0 || value === "";
1758
- if (check && !empty) {
1759
- const result = await check(value, { ...ctx, field: own });
1758
+ if (check2 && !empty) {
1759
+ const result = await check2(value, { ...ctx, field: own });
1760
1760
  if (result !== true) return result;
1761
1761
  }
1762
1762
  return ownValidate ? ownValidate(value, ctx) : true;
@@ -2388,7 +2388,32 @@ function validateUpload(config, add) {
2388
2388
  if (upload.dir !== void 0 && (typeof upload.dir !== "string" || upload.dir.length === 0)) {
2389
2389
  add("upload.dir", "must be a non-empty string");
2390
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");
2391
2415
  }
2416
+ var HOST_PATTERN = /^(\*|(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*)$/i;
2392
2417
  function asArray(value, path, add) {
2393
2418
  if (value === void 0) return [];
2394
2419
  if (!Array.isArray(value)) {
@@ -2840,7 +2865,8 @@ async function resolveConfig(input) {
2840
2865
  maxFileSize: config.upload?.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
2841
2866
  mimeTypes: config.upload?.mimeTypes ?? ["image/*", "application/pdf"],
2842
2867
  imageSizes: config.upload?.imageSizes ?? [],
2843
- ...config.upload?.storage ? { storage: config.upload.storage } : {}
2868
+ ...config.upload?.storage ? { storage: config.upload.storage } : {},
2869
+ ...config.upload?.fromURL ? { fromURL: config.upload.fromURL } : {}
2844
2870
  },
2845
2871
  auth: {
2846
2872
  roles: config.auth?.roles ?? DEFAULT_ROLES,
@@ -3195,6 +3221,212 @@ function collectSlots(fields, data, bySlug, slots) {
3195
3221
  }
3196
3222
  }
3197
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
+
3198
3430
  // src/storage.ts
3199
3431
  import { mkdir, readFile, rm, stat, writeFile } from "fs/promises";
3200
3432
  import { dirname, isAbsolute, join, normalize, resolve as resolve2, sep } from "path";
@@ -3557,6 +3789,39 @@ var EasyCMS = class {
3557
3789
  throw error;
3558
3790
  }
3559
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
+ }
3560
3825
  /**
3561
3826
  * Creates an API key (needs `apiKeys: true`) and returns it once: only a hash is stored.
3562
3827
  * With access enforced the key belongs to `user`; trusted calls name the owner in `data.user`.
@@ -3766,7 +4031,7 @@ var EasyCMS = class {
3766
4031
  /** Coerces preview input like a save would, keeping values that would fail validation. */
3767
4032
  async previewDoc(config, data, system2, options = {}) {
3768
4033
  const locale = this.localeOf(options);
3769
- const { data: clean } = await validateFields(config.fields, data, {
4034
+ const { data: clean2 } = await validateFields(config.fields, data, {
3770
4035
  operation: "update",
3771
4036
  root: data,
3772
4037
  skipRequired: true,
@@ -3775,7 +4040,7 @@ var EasyCMS = class {
3775
4040
  });
3776
4041
  return {
3777
4042
  ...data,
3778
- ...clean,
4043
+ ...clean2,
3779
4044
  ...config.drafts ? { status: data.status } : {},
3780
4045
  ...system2,
3781
4046
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -4064,14 +4329,14 @@ var EasyCMS = class {
4064
4329
  const parsed = parseId(id);
4065
4330
  if (parsed === void 0) return { update: false, delete: false };
4066
4331
  const guard = { enforce: true, user };
4067
- 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(
4068
4333
  () => true,
4069
4334
  (error) => {
4070
4335
  if (error instanceof ForbiddenError || error instanceof UnauthorizedError) return false;
4071
4336
  throw error;
4072
4337
  }
4073
4338
  );
4074
- return { update: await check("update"), delete: await check("delete") };
4339
+ return { update: await check2("update"), delete: await check2("delete") };
4075
4340
  }
4076
4341
  /** Closes the database connection. */
4077
4342
  /**
@@ -4445,7 +4710,7 @@ var EasyCMS = class {
4445
4710
  }
4446
4711
  /** Runs afterRead hooks, populates relationships and removes what the caller may not see. */
4447
4712
  async output(config, docs, guard, options) {
4448
- const read = this.fieldChecker("read", guard, void 0, void 0);
4713
+ const read2 = this.fieldChecker("read", guard, void 0, void 0);
4449
4714
  const finish = async (target, doc) => {
4450
4715
  const hooked = await this.transform(
4451
4716
  target.hooks?.afterRead,
@@ -4453,7 +4718,7 @@ var EasyCMS = class {
4453
4718
  this.hookArgs(target, guard),
4454
4719
  doc
4455
4720
  );
4456
- return await stripFields(target.fields, hooked, read);
4721
+ return await stripFields(target.fields, hooked, read2);
4457
4722
  };
4458
4723
  const load = async (target, ids) => {
4459
4724
  const where = await this.readWhere(target, guard, { id: { in: [...ids] } }).catch((error) => {
@@ -4696,24 +4961,24 @@ var EasyCMS = class {
4696
4961
  ...locale !== void 0 && locale !== ALL_LOCALES ? { locale } : {}
4697
4962
  });
4698
4963
  const errors = [...result.errors];
4699
- const clean = { ...result.data };
4964
+ const clean2 = { ...result.data };
4700
4965
  if (config.drafts) {
4701
4966
  const status = data.status ?? "draft";
4702
4967
  if (status !== "draft" && status !== "published") {
4703
4968
  errors.push({ field: "status", message: 'must be "draft" or "published"' });
4704
4969
  }
4705
- clean.status = status;
4970
+ clean2.status = status;
4706
4971
  }
4707
4972
  const isCollection = this.config.collections.includes(config);
4708
4973
  if (isCollection && errors.length === 0) {
4709
- await this.makeSlugsUnique(config, clean, selfId);
4710
- errors.push(...await this.checkUnique(config, clean, selfId));
4974
+ await this.makeSlugsUnique(config, clean2, selfId);
4975
+ errors.push(...await this.checkUnique(config, clean2, selfId));
4711
4976
  }
4712
4977
  errors.push(...await this.checkReferences(result.references));
4713
4978
  if (errors.length === 0)
4714
4979
  errors.push(...await this.checkFilterOptions(result.references, selfId, user));
4715
4980
  if (errors.length > 0) throw new ValidationError(config.slug, errors);
4716
- return clean;
4981
+ return clean2;
4717
4982
  }
4718
4983
  /** Refuses changes that would leave no active admin (and lock everyone out). */
4719
4984
  async guardLastAdmin(id, before, after) {
@@ -5084,6 +5349,7 @@ async function adminSchema(cms, user, origin) {
5084
5349
  collections: await Promise.all(collections.map((c) => collection(c, user, localized))),
5085
5350
  globals: await Promise.all(cms.config.globals.map((g) => global(g, user, localized))),
5086
5351
  modules: adminModuleUrls(cms),
5352
+ uploadFromURL: cms.config.upload.fromURL !== void 0,
5087
5353
  pages: await pages(cms, user),
5088
5354
  dashboard: await widgets(cms, user)
5089
5355
  };
@@ -5545,6 +5811,15 @@ async function route(cms, ctx, method, segments, options) {
5545
5811
  return { body: await serveFile(cms, third, method === "HEAD") };
5546
5812
  }
5547
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
+ }
5548
5823
  const { file, data } = await readUpload(ctx.request, cms.config.upload.maxFileSize);
5549
5824
  return {
5550
5825
  status: 201,
@@ -5821,7 +6096,10 @@ async function serveFile(cms, key2, head) {
5821
6096
  async function readUpload(request, maxFileSize) {
5822
6097
  const type = request.headers.get("content-type") ?? "";
5823
6098
  if (!type.toLowerCase().startsWith("multipart/form-data")) {
5824
- 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
+ );
5825
6103
  }
5826
6104
  const declared = Number(request.headers.get("content-length") ?? 0);
5827
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.27.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",