@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 +29 -3
- package/dist/index.js +299 -21
- package/package.json +1 -1
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
|
-
/**
|
|
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,
|
|
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 ||
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
...
|
|
1755
|
+
...check2 || ownValidate ? {
|
|
1756
1756
|
validate: async (value, ctx) => {
|
|
1757
1757
|
const empty = value === null || value === void 0 || value === "";
|
|
1758
|
-
if (
|
|
1759
|
-
const result = await
|
|
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:
|
|
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
|
-
...
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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,
|
|
4710
|
-
errors.push(...await this.checkUnique(config,
|
|
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
|
|
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(
|
|
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)
|