@easy-cms/core 0.16.1 → 0.18.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 +16 -2
- package/dist/index.js +83 -34
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -1275,10 +1275,17 @@ interface EndpointRequest {
|
|
|
1275
1275
|
interface Endpoint {
|
|
1276
1276
|
/**
|
|
1277
1277
|
* Path under the API, e.g. `/seo/generate` or `/stats/:collection`. The first segment must
|
|
1278
|
-
* not be a collection slug or one of `users`, `globals`, `admin`, `jobs`.
|
|
1278
|
+
* not be a collection slug or one of `users`, `globals`, `admin`, `jobs`. With `root`, the
|
|
1279
|
+
* path from the site's root, outside the API and the admin.
|
|
1279
1280
|
*/
|
|
1280
1281
|
readonly path: string;
|
|
1281
1282
|
readonly method: 'get' | 'post' | 'put' | 'patch' | 'delete';
|
|
1283
|
+
/**
|
|
1284
|
+
* Serve the path from the site's root instead of under `routes.api`, e.g. `/robots.txt`.
|
|
1285
|
+
* Only the standalone server (`easy-cms serve`) serves these; a Nuxt or Next.js app owns its
|
|
1286
|
+
* root, so plugins also offer a helper to use in the app's own route. Default false.
|
|
1287
|
+
*/
|
|
1288
|
+
readonly root?: boolean;
|
|
1282
1289
|
/**
|
|
1283
1290
|
* Returns a `Response`, or a value that is sent as JSON. Throw an Easy CMS error
|
|
1284
1291
|
* (`UnauthorizedError`, `ForbiddenError`, `ValidationError`, `NotFoundError`) for error responses.
|
|
@@ -1699,6 +1706,13 @@ interface RestHandlerOptions {
|
|
|
1699
1706
|
type RestHandler = (request: Request) => Promise<Response>;
|
|
1700
1707
|
/** Creates the REST API as a Web-standard `(Request) => Response` handler. */
|
|
1701
1708
|
declare function createRestHandler<C extends Config>(instance: EasyCMS<C>, options?: RestHandlerOptions): RestHandler;
|
|
1709
|
+
/**
|
|
1710
|
+
* Serves the endpoints marked `root: true` (e.g. `/robots.txt`), with the same auth, CSRF and
|
|
1711
|
+
* errors as the REST API. Resolves to `undefined` when no root endpoint has the request's path,
|
|
1712
|
+
* so a server can go on to its other routes. Nuxt and Next.js apps own their root and don't
|
|
1713
|
+
* use it; the standalone server does.
|
|
1714
|
+
*/
|
|
1715
|
+
declare function createRootEndpointHandler<C extends Config>(instance: EasyCMS<C>, options?: Pick<RestHandlerOptions, 'getClientIp'>): (request: Request) => Promise<Response | undefined>;
|
|
1702
1716
|
declare function readCookie(request: Request, name: string): string | undefined;
|
|
1703
1717
|
|
|
1704
1718
|
/** Best-effort English singular: "categories" → "category". */
|
|
@@ -1715,4 +1729,4 @@ declare const BUILTIN_COLLECTIONS: readonly ["users", "media"];
|
|
|
1715
1729
|
/** Checks a config and returns every problem found. An empty array means it is valid. */
|
|
1716
1730
|
declare function validateConfig(config: Config): ConfigIssue[];
|
|
1717
1731
|
|
|
1718
|
-
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 CollectionAccess, type CollectionConfig, type CollectionDocument, type CollectionHooks, type CollectionSlug, type Config, ConfigError, type ConfigIssue, type ContainerAdmin, type CopyProgress, type CopyResult, type CreateEasyCMSOptions, type CreateInput, type CreateMigrationResult, DEFAULT_ADMIN_PATH, DEFAULT_API_PATH, DEFAULT_DEPTH, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_VERSIONS, DEFAULT_TOKEN_EXPIRATION, type Database, type DatabaseAdapter, type DatabaseInitArgs, type DatabaseTransfer, type DateField, type DepthOptions, EXTENSIONS, EasyCMS, EasyCMSError, type EmailField, type Endpoint, type EndpointRequest, FIELD_TYPES, type Field, type FieldAccess, type FieldAccessFn, type FieldAdmin, type FieldError, type FieldType, type FieldValidate, type FieldValidateContext, type FieldValue, type FieldsInput, type FieldsValue, 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, PayloadTooLargeError, type Plugin, 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 StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, UnauthorizedError, type UpdateInput, 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, consoleLogger, copyDatabase, createEasyCMS, createRestHandler, defineConfig, fillMissing, findConfigFile, generateSlugs, generateTypes, hasRows, hashPassword, imageDimensions, importConfig, isAdmin, isLoggedIn, keyAllows, loadConfig, localStorage, mergeForUpdate, mimeAllowed, parseId, populate, readCookie, resolveAdminModule, resolveConfig, rowFields, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword };
|
|
1732
|
+
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 CollectionAccess, type CollectionConfig, type CollectionDocument, type CollectionHooks, type CollectionSlug, type Config, ConfigError, type ConfigIssue, type ContainerAdmin, type CopyProgress, type CopyResult, type CreateEasyCMSOptions, type CreateInput, type CreateMigrationResult, DEFAULT_ADMIN_PATH, DEFAULT_API_PATH, DEFAULT_DEPTH, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_VERSIONS, DEFAULT_TOKEN_EXPIRATION, type Database, type DatabaseAdapter, type DatabaseInitArgs, type DatabaseTransfer, type DateField, type DepthOptions, EXTENSIONS, EasyCMS, EasyCMSError, type EmailField, type Endpoint, type EndpointRequest, FIELD_TYPES, type Field, type FieldAccess, type FieldAccessFn, type FieldAdmin, type FieldError, type FieldType, type FieldValidate, type FieldValidateContext, type FieldValue, type FieldsInput, type FieldsValue, 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, PayloadTooLargeError, type Plugin, 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 StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, UnauthorizedError, type UpdateInput, 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, consoleLogger, copyDatabase, createEasyCMS, createRestHandler, createRootEndpointHandler, defineConfig, fillMissing, findConfigFile, generateSlugs, generateTypes, hasRows, hashPassword, imageDimensions, importConfig, isAdmin, isLoggedIn, keyAllows, loadConfig, localStorage, mergeForUpdate, mimeAllowed, parseId, populate, readCookie, resolveAdminModule, resolveConfig, rowFields, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword };
|
package/dist/index.js
CHANGED
|
@@ -2016,18 +2016,31 @@ function validateEndpoints(config, collectionSlugs, add) {
|
|
|
2016
2016
|
add(path, "must be an object { path, method, handler }");
|
|
2017
2017
|
return;
|
|
2018
2018
|
}
|
|
2019
|
-
const { path: route2, method, handler } = endpoint;
|
|
2019
|
+
const { path: route2, method, handler, root } = endpoint;
|
|
2020
|
+
if (root !== void 0 && typeof root !== "boolean")
|
|
2021
|
+
add(`${path}.root`, "must be true or false");
|
|
2020
2022
|
const segments = typeof route2 === "string" ? route2.split("/").slice(1) : [];
|
|
2021
2023
|
if (typeof route2 !== "string" || !route2.startsWith("/") || segments.length === 0 || !segments.every((s) => ENDPOINT_SEGMENT.test(s))) {
|
|
2022
2024
|
add(`${path}.path`, `must be a path like "/seo/generate" (got ${JSON.stringify(route2)})`);
|
|
2023
2025
|
} else {
|
|
2024
|
-
const
|
|
2025
|
-
if (
|
|
2026
|
+
const first = segments[0];
|
|
2027
|
+
if (first.startsWith(":")) {
|
|
2026
2028
|
add(`${path}.path`, "must start with a fixed segment, not a parameter");
|
|
2027
|
-
} else if (
|
|
2029
|
+
} else if (root === true) {
|
|
2030
|
+
const taken = [
|
|
2031
|
+
config.routes?.api ?? "/api/cms",
|
|
2032
|
+
`/${(config.admin?.path ?? "/admin").replace(/^\/+|\/+$/g, "")}`,
|
|
2033
|
+
"/healthz"
|
|
2034
|
+
].map((p) => p.replace(/\/+$/, "")).find((p) => route2 === p || route2.startsWith(`${p}/`) || p.startsWith(`${route2}/`));
|
|
2035
|
+
if (taken)
|
|
2036
|
+
add(
|
|
2037
|
+
`${path}.path`,
|
|
2038
|
+
`"${route2}" is served by ${taken === "/healthz" ? "the standalone server" : `"${taken}"`}`
|
|
2039
|
+
);
|
|
2040
|
+
} else if (RESERVED_ENDPOINT_ROOTS.has(first) || collectionSlugs.has(first)) {
|
|
2028
2041
|
add(
|
|
2029
2042
|
`${path}.path`,
|
|
2030
|
-
`"/${
|
|
2043
|
+
`"/${first}" is used by the built-in API`,
|
|
2031
2044
|
'start the path with your plugin or feature name, e.g. "/seo/generate"'
|
|
2032
2045
|
);
|
|
2033
2046
|
}
|
|
@@ -2041,7 +2054,7 @@ function validateEndpoints(config, collectionSlugs, add) {
|
|
|
2041
2054
|
}
|
|
2042
2055
|
if (typeof handler !== "function") add(`${path}.handler`, "must be a function");
|
|
2043
2056
|
if (typeof route2 === "string" && typeof verb === "string") {
|
|
2044
|
-
const key2 = `${verb} ${route2.replace(/:[A-Za-z_][A-Za-z0-9_]*/g, ":")}`;
|
|
2057
|
+
const key2 = `${root === true ? "root " : ""}${verb} ${route2.replace(/:[A-Za-z_][A-Za-z0-9_]*/g, ":")}`;
|
|
2045
2058
|
if (seen.has(key2)) add(`${path}.path`, `duplicate endpoint ${verb.toUpperCase()} ${route2}`);
|
|
2046
2059
|
seen.add(key2);
|
|
2047
2060
|
}
|
|
@@ -4358,6 +4371,42 @@ function createRestHandler(instance, options = {}) {
|
|
|
4358
4371
|
const cms = instance;
|
|
4359
4372
|
const basePath = (options.basePath ?? cms.config.routes.api).replace(/\/+$/, "");
|
|
4360
4373
|
const production = process.env.NODE_ENV === "production";
|
|
4374
|
+
return withCors(cms, (request) => {
|
|
4375
|
+
const url = new URL(request.url);
|
|
4376
|
+
const inside = url.pathname === basePath || url.pathname.startsWith(`${basePath}/`);
|
|
4377
|
+
return respond(
|
|
4378
|
+
cms,
|
|
4379
|
+
request,
|
|
4380
|
+
url,
|
|
4381
|
+
inside ? url.pathname.slice(basePath.length) : null,
|
|
4382
|
+
false,
|
|
4383
|
+
options,
|
|
4384
|
+
production
|
|
4385
|
+
);
|
|
4386
|
+
});
|
|
4387
|
+
}
|
|
4388
|
+
function createRootEndpointHandler(instance, options = {}) {
|
|
4389
|
+
const cms = instance;
|
|
4390
|
+
const production = process.env.NODE_ENV === "production";
|
|
4391
|
+
const handler = withCors(cms, (request) => {
|
|
4392
|
+
const url = new URL(request.url);
|
|
4393
|
+
return respond(cms, request, url, url.pathname, true, options, production);
|
|
4394
|
+
});
|
|
4395
|
+
return async (request) => {
|
|
4396
|
+
let segments;
|
|
4397
|
+
try {
|
|
4398
|
+
segments = pathSegments(new URL(request.url).pathname);
|
|
4399
|
+
} catch {
|
|
4400
|
+
return void 0;
|
|
4401
|
+
}
|
|
4402
|
+
const matched = cms.config.endpoints.some((e) => e.root && matchPath(e.path, segments));
|
|
4403
|
+
return matched ? handler(request) : void 0;
|
|
4404
|
+
};
|
|
4405
|
+
}
|
|
4406
|
+
function pathSegments(path) {
|
|
4407
|
+
return path.split("/").filter(Boolean).map((s) => decodeURIComponent(s));
|
|
4408
|
+
}
|
|
4409
|
+
function withCors(cms, handle) {
|
|
4361
4410
|
return async (request) => {
|
|
4362
4411
|
const cors = corsHeaders(cms, request);
|
|
4363
4412
|
if (request.method.toUpperCase() === "OPTIONS") {
|
|
@@ -4365,31 +4414,29 @@ function createRestHandler(instance, options = {}) {
|
|
|
4365
4414
|
}
|
|
4366
4415
|
return withHeaders(await handle(request), cors);
|
|
4367
4416
|
};
|
|
4368
|
-
|
|
4369
|
-
|
|
4370
|
-
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
4374
|
-
|
|
4375
|
-
|
|
4376
|
-
|
|
4377
|
-
|
|
4378
|
-
|
|
4379
|
-
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
return errorResponse(cms, error, headers, production);
|
|
4392
|
-
}
|
|
4417
|
+
}
|
|
4418
|
+
async function respond(cms, request, url, path, root, options, production) {
|
|
4419
|
+
const headers = new Headers({
|
|
4420
|
+
"content-type": "application/json; charset=utf-8",
|
|
4421
|
+
"cache-control": "no-store",
|
|
4422
|
+
"x-content-type-options": "nosniff"
|
|
4423
|
+
});
|
|
4424
|
+
try {
|
|
4425
|
+
if (path === null) throw new HttpError("Not found", 404);
|
|
4426
|
+
const segments = pathSegments(path);
|
|
4427
|
+
const { token, via } = readToken(request);
|
|
4428
|
+
const user = token ? await cms.auth.verify(token) : null;
|
|
4429
|
+
if (!user && via === "bearer" && token?.startsWith("ecms_"))
|
|
4430
|
+
throw new UnauthorizedError("Invalid or expired API key");
|
|
4431
|
+
const ctx = { request, url, user, via: user ? via : null, token, headers };
|
|
4432
|
+
const method = request.method.toUpperCase();
|
|
4433
|
+
if (method !== "GET" && method !== "HEAD") checkCsrf(cms, ctx);
|
|
4434
|
+
const result = root ? await customEndpoint(cms, ctx, method, segments, true) : await customEndpoint(cms, ctx, method, segments, false) ?? await route(cms, ctx, method, segments, options);
|
|
4435
|
+
if (!result) throw new HttpError("Not found", 404);
|
|
4436
|
+
if (result.body instanceof Response) return result.body;
|
|
4437
|
+
return new Response(JSON.stringify(result.body), { status: result.status ?? 200, headers });
|
|
4438
|
+
} catch (error) {
|
|
4439
|
+
return errorResponse(cms, error, headers, production);
|
|
4393
4440
|
}
|
|
4394
4441
|
}
|
|
4395
4442
|
var CORS_METHODS = "GET, HEAD, POST, PATCH, PUT, DELETE";
|
|
@@ -4422,9 +4469,10 @@ function withHeaders(response, extra) {
|
|
|
4422
4469
|
headers
|
|
4423
4470
|
});
|
|
4424
4471
|
}
|
|
4425
|
-
async function customEndpoint(cms, ctx, method, segments) {
|
|
4472
|
+
async function customEndpoint(cms, ctx, method, segments, root) {
|
|
4426
4473
|
let pathMatched = false;
|
|
4427
|
-
const
|
|
4474
|
+
const own = cms.config.endpoints.filter((e) => !!e.root === root);
|
|
4475
|
+
const endpoints = [...own].sort((a, b) => fixedSegments(b) - fixedSegments(a));
|
|
4428
4476
|
for (const endpoint of endpoints) {
|
|
4429
4477
|
const params = matchPath(endpoint.path, segments);
|
|
4430
4478
|
if (!params) continue;
|
|
@@ -4442,7 +4490,7 @@ async function customEndpoint(cms, ctx, method, segments) {
|
|
|
4442
4490
|
return { body: body === void 0 ? null : body };
|
|
4443
4491
|
}
|
|
4444
4492
|
if (pathMatched) {
|
|
4445
|
-
const allow =
|
|
4493
|
+
const allow = own.filter((e) => matchPath(e.path, segments)).map((e) => e.method.toUpperCase());
|
|
4446
4494
|
throw methodNotAllowed(ctx, [...new Set(allow)].join(", "));
|
|
4447
4495
|
}
|
|
4448
4496
|
return void 0;
|
|
@@ -5122,6 +5170,7 @@ export {
|
|
|
5122
5170
|
copyDatabase,
|
|
5123
5171
|
createEasyCMS,
|
|
5124
5172
|
createRestHandler,
|
|
5173
|
+
createRootEndpointHandler,
|
|
5125
5174
|
defineConfig,
|
|
5126
5175
|
fillMissing,
|
|
5127
5176
|
findConfigFile,
|