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