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