@easy-cms/core 0.1.1 → 0.3.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 +85 -2
- package/dist/index.js +535 -26
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -332,6 +332,10 @@ interface GlobalHooks {
|
|
|
332
332
|
readonly afterChange?: readonly AfterChangeHook[];
|
|
333
333
|
readonly afterRead?: readonly AfterReadHook[];
|
|
334
334
|
}
|
|
335
|
+
interface VersionsConfig {
|
|
336
|
+
/** Versions kept per document; older ones are deleted. Default 50. */
|
|
337
|
+
readonly max?: number;
|
|
338
|
+
}
|
|
335
339
|
interface CollectionConfig {
|
|
336
340
|
/** URL and table name. Lowercase letters, digits, `-` and `_`. */
|
|
337
341
|
readonly slug: string;
|
|
@@ -344,6 +348,12 @@ interface CollectionConfig {
|
|
|
344
348
|
readonly useAsTitle?: string;
|
|
345
349
|
/** Adds a `status` field (`draft` | `published`). */
|
|
346
350
|
readonly drafts?: boolean;
|
|
351
|
+
/**
|
|
352
|
+
* Keep a snapshot of every save, to view history and restore. With `drafts`, a draft saved
|
|
353
|
+
* on a published document is kept as a version and the published document stays live until
|
|
354
|
+
* it is published again.
|
|
355
|
+
*/
|
|
356
|
+
readonly versions?: boolean | VersionsConfig;
|
|
347
357
|
readonly access?: CollectionAccess;
|
|
348
358
|
readonly hooks?: CollectionHooks;
|
|
349
359
|
}
|
|
@@ -352,6 +362,8 @@ interface GlobalConfig {
|
|
|
352
362
|
readonly label?: Label;
|
|
353
363
|
readonly fields: readonly Field[];
|
|
354
364
|
readonly drafts?: boolean;
|
|
365
|
+
/** Keep a snapshot of every save. See `CollectionConfig.versions`. */
|
|
366
|
+
readonly versions?: boolean | VersionsConfig;
|
|
355
367
|
readonly access?: GlobalAccess;
|
|
356
368
|
readonly hooks?: GlobalHooks;
|
|
357
369
|
}
|
|
@@ -412,6 +424,12 @@ interface Config {
|
|
|
412
424
|
* so frontends on other origins can use them. Default: relative URLs.
|
|
413
425
|
*/
|
|
414
426
|
readonly serverURL?: string;
|
|
427
|
+
/**
|
|
428
|
+
* Origins whose browser code may call the REST API (CORS), e.g. a Vite or React app on
|
|
429
|
+
* another origin. `'*'` allows any origin for anonymous requests. Origins in
|
|
430
|
+
* `auth.trustedOrigins` are always allowed, with cookies. Default: none.
|
|
431
|
+
*/
|
|
432
|
+
readonly cors?: readonly string[] | '*';
|
|
415
433
|
readonly routes?: RoutesConfig;
|
|
416
434
|
readonly admin?: AdminConfig;
|
|
417
435
|
readonly upload?: UploadConfig;
|
|
@@ -425,7 +443,8 @@ interface Config {
|
|
|
425
443
|
readonly plugins?: readonly Plugin[];
|
|
426
444
|
}
|
|
427
445
|
/** The config after plugins ran, validation passed and defaults were applied. */
|
|
428
|
-
interface ResolvedConfig extends Omit<Config, 'routes' | 'admin' | 'upload' | 'auth' | 'collections' | 'globals' | 'plugins'> {
|
|
446
|
+
interface ResolvedConfig extends Omit<Config, 'cors' | 'routes' | 'admin' | 'upload' | 'auth' | 'collections' | 'globals' | 'plugins'> {
|
|
447
|
+
readonly cors: readonly string[] | '*';
|
|
429
448
|
readonly routes: Required<RoutesConfig>;
|
|
430
449
|
readonly admin: Required<AdminConfig>;
|
|
431
450
|
readonly upload: Required<Omit<UploadConfig, 'storage'>> & Pick<UploadConfig, 'storage'>;
|
|
@@ -640,6 +659,23 @@ type GlobalInput<C extends Config, S extends GlobalSlug<C>> = Partial<Simplify<F
|
|
|
640
659
|
readonly slug: S;
|
|
641
660
|
}>>>>;
|
|
642
661
|
|
|
662
|
+
declare const DEFAULT_MAX_VERSIONS = 50;
|
|
663
|
+
/** One saved state of a document or global. */
|
|
664
|
+
interface VersionSummary {
|
|
665
|
+
readonly id: ID;
|
|
666
|
+
/** `draft` or `published` with drafts enabled, otherwise `null`. */
|
|
667
|
+
readonly status: 'draft' | 'published' | null;
|
|
668
|
+
/** The newest version: the document's current editable state. */
|
|
669
|
+
readonly latest: boolean;
|
|
670
|
+
/** Who saved it (user id), or `null` for saves without a user (scripts, seeds). */
|
|
671
|
+
readonly author: ID | null;
|
|
672
|
+
readonly createdAt: string;
|
|
673
|
+
}
|
|
674
|
+
interface Version<T = Record<string, unknown>> extends VersionSummary {
|
|
675
|
+
/** The document as it was saved (hidden fields such as password hashes are never kept). */
|
|
676
|
+
readonly data: T;
|
|
677
|
+
}
|
|
678
|
+
|
|
643
679
|
type Data$1 = Record<string, unknown>;
|
|
644
680
|
type Slug<C extends Config> = string extends CollectionSlug<C> ? string : CollectionSlug<C>;
|
|
645
681
|
type GSlug<C extends Config> = string extends GlobalSlug<C> ? string : GlobalSlug<C>;
|
|
@@ -695,6 +731,7 @@ declare class EasyCMS<C extends Config = Config> {
|
|
|
695
731
|
readonly auth: Auth;
|
|
696
732
|
/** Where uploaded files are stored. */
|
|
697
733
|
readonly storage: StorageAdapter;
|
|
734
|
+
private readonly versions;
|
|
698
735
|
constructor(config: ResolvedConfig, db: Database, logger?: Logger, storage?: StorageAdapter);
|
|
699
736
|
find<S extends Slug<C>>(collection: S, options?: FindOptions): Promise<PaginatedDocs<Doc<C, S>>>;
|
|
700
737
|
findById<S extends Slug<C>>(collection: S, id: ID, options?: ReadOptions): Promise<Doc<C, S> | null>;
|
|
@@ -714,9 +751,47 @@ declare class EasyCMS<C extends Config = Config> {
|
|
|
714
751
|
/** Public URL of a stored file. */
|
|
715
752
|
mediaURL(key: string): string;
|
|
716
753
|
update<S extends Slug<C>>(collection: S, id: ID, data: Update<C, S>, options?: DepthOptions): Promise<Doc<C, S>>;
|
|
754
|
+
/**
|
|
755
|
+
* Takes a document off the site: its status becomes `draft`. With versions and drafts, a plain
|
|
756
|
+
* `update` with `status: 'draft'` only saves a draft and leaves the published document live;
|
|
757
|
+
* this is how to unpublish it.
|
|
758
|
+
*/
|
|
759
|
+
unpublish<S extends Slug<C>>(collection: S, id: ID, options?: DepthOptions): Promise<Doc<C, S>>;
|
|
760
|
+
/** Throws away the unpublished draft of a published document, going back to what is live. */
|
|
761
|
+
discardDraft<S extends Slug<C>>(collection: S, id: ID, options?: DepthOptions): Promise<Doc<C, S>>;
|
|
762
|
+
/** Saved versions of a document, newest first. Needs update access to the document. */
|
|
763
|
+
findVersions<S extends Slug<C>>(collection: S, id: ID, options?: AccessOptions & {
|
|
764
|
+
limit?: number;
|
|
765
|
+
page?: number;
|
|
766
|
+
}): Promise<PaginatedDocs<VersionSummary>>;
|
|
767
|
+
/** One saved version with the document as it was then. */
|
|
768
|
+
findVersion<S extends Slug<C>>(collection: S, id: ID, versionId: ID, options?: DepthOptions): Promise<Version<Doc<C, S>> | null>;
|
|
769
|
+
/**
|
|
770
|
+
* Makes an old version the current content, as a new version. With drafts it is restored as
|
|
771
|
+
* a draft (publish it to put it live); access, hooks and validation run as for `update`.
|
|
772
|
+
*/
|
|
773
|
+
restoreVersion<S extends Slug<C>>(collection: S, id: ID, versionId: ID, options?: DepthOptions): Promise<Doc<C, S>>;
|
|
774
|
+
private versionTarget;
|
|
775
|
+
private updateDocument;
|
|
717
776
|
delete<S extends Slug<C>>(collection: S, id: ID, options?: AccessOptions): Promise<Doc<C, S>>;
|
|
718
777
|
findGlobal<S extends GSlug<C>>(slug: S, options?: ReadOptions): Promise<GDoc<C, S>>;
|
|
719
778
|
updateGlobal<S extends GSlug<C>>(slug: S, data: GInput<C, S>, options?: DepthOptions): Promise<GDoc<C, S>>;
|
|
779
|
+
/** Takes a global off the site (`status: 'draft'`). See `unpublish`. */
|
|
780
|
+
unpublishGlobal<S extends GSlug<C>>(slug: S, options?: DepthOptions): Promise<GDoc<C, S>>;
|
|
781
|
+
/** Throws away the unpublished draft of a published global. */
|
|
782
|
+
discardGlobalDraft<S extends GSlug<C>>(slug: S, options?: DepthOptions): Promise<GDoc<C, S>>;
|
|
783
|
+
/** Saved versions of a global, newest first. Needs update access. */
|
|
784
|
+
findGlobalVersions<S extends GSlug<C>>(slug: S, options?: AccessOptions & {
|
|
785
|
+
limit?: number;
|
|
786
|
+
page?: number;
|
|
787
|
+
}): Promise<PaginatedDocs<VersionSummary>>;
|
|
788
|
+
findGlobalVersion<S extends GSlug<C>>(slug: S, versionId: ID, options?: DepthOptions): Promise<Version<GDoc<C, S>> | null>;
|
|
789
|
+
/** Makes an old version of a global current (as a draft when the global has drafts). */
|
|
790
|
+
restoreGlobalVersion<S extends GSlug<C>>(slug: S, versionId: ID, options?: DepthOptions): Promise<GDoc<C, S>>;
|
|
791
|
+
private globalVersionTarget;
|
|
792
|
+
/** The global's pending draft when it has one (see `withDrafts`), otherwise `saved`. */
|
|
793
|
+
private globalDraft;
|
|
794
|
+
private saveGlobal;
|
|
720
795
|
/**
|
|
721
796
|
* What `user` may do with one document. Resolves `where`-style access
|
|
722
797
|
* against the document, so the admin UI can hide actions precisely.
|
|
@@ -739,6 +814,12 @@ declare class EasyCMS<C extends Config = Config> {
|
|
|
739
814
|
private output;
|
|
740
815
|
/** Creates a document: access, input filtering, hooks, validation, then afterChange. */
|
|
741
816
|
private createDocument;
|
|
817
|
+
/** Drafts are kept as versions (instead of unpublishing) when both are enabled. */
|
|
818
|
+
private separateDrafts;
|
|
819
|
+
/** Replaces published documents that have a newer draft with that draft. */
|
|
820
|
+
private withDrafts;
|
|
821
|
+
/** Records a version after a save, when the collection or global keeps versions. */
|
|
822
|
+
private saveVersion;
|
|
742
823
|
private hookArgs;
|
|
743
824
|
/**
|
|
744
825
|
* Runs hooks that may replace a value, passed as `data` (before hooks) or `doc` (afterRead).
|
|
@@ -994,6 +1075,7 @@ interface AdminCollection {
|
|
|
994
1075
|
};
|
|
995
1076
|
useAsTitle?: string;
|
|
996
1077
|
drafts: boolean;
|
|
1078
|
+
versions: boolean;
|
|
997
1079
|
fields: AdminField[];
|
|
998
1080
|
permissions: {
|
|
999
1081
|
read: boolean;
|
|
@@ -1006,6 +1088,7 @@ interface AdminGlobal {
|
|
|
1006
1088
|
slug: string;
|
|
1007
1089
|
label?: Label;
|
|
1008
1090
|
drafts: boolean;
|
|
1091
|
+
versions: boolean;
|
|
1009
1092
|
fields: AdminField[];
|
|
1010
1093
|
permissions: {
|
|
1011
1094
|
read: boolean;
|
|
@@ -1046,4 +1129,4 @@ declare const BUILTIN_COLLECTIONS: readonly ["users", "media"];
|
|
|
1046
1129
|
/** Checks a config and returns every problem found. An empty array means it is valid. */
|
|
1047
1130
|
declare function validateConfig(config: Config): ConfigIssue[];
|
|
1048
1131
|
|
|
1049
|
-
export { type Access, type AccessArgs, type AccessOptions, type AdminCollection, type AdminConfig, type AdminField, type AdminGlobal, type AdminLocale, type AdminSchema, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ArrayField, Auth, type AuthConfig, type AuthUser, BUILTIN_COLLECTIONS, type BeforeChangeHook, type BeforeDeleteHook, type BeforeValidateHook, type BooleanField, 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 CreateEasyCMSOptions, type CreateInput, type CreateMigrationResult, DEFAULT_ADMIN_PATH, DEFAULT_API_PATH, DEFAULT_DEPTH, DEFAULT_MAX_FILE_SIZE, DEFAULT_TOKEN_EXPIRATION, type Database, type DatabaseAdapter, type DatabaseInitArgs, type DateField, type DepthOptions, EXTENSIONS, EasyCMS, EasyCMSError, type EmailField, FIELD_TYPES, type Field, type FieldAccess, type FieldAccessFn, 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 LoadConfigOptions, type LocalStorageOptions, 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, QueryError, type RawDocument, type ReadOptions, type RelationshipField, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoutesConfig, SESSION_COOKIE, 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 Where, type WhereOperators, anyone, applyDefaults, consoleLogger, createEasyCMS, createRestHandler, defineConfig, fillMissing, findConfigFile, generateSlugs, generateTypes, hashPassword, imageDimensions, importConfig, isAdmin, isLoggedIn, loadConfig, localStorage, mergeForUpdate, mimeAllowed, parseId, populate, readCookie, resolveConfig, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword };
|
|
1132
|
+
export { type Access, type AccessArgs, type AccessOptions, type AdminCollection, type AdminConfig, type AdminField, type AdminGlobal, type AdminLocale, type AdminSchema, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ArrayField, Auth, type AuthConfig, type AuthUser, BUILTIN_COLLECTIONS, type BeforeChangeHook, type BeforeDeleteHook, type BeforeValidateHook, type BooleanField, 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 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 DateField, type DepthOptions, EXTENSIONS, EasyCMS, EasyCMSError, type EmailField, FIELD_TYPES, type Field, type FieldAccess, type FieldAccessFn, 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 LoadConfigOptions, type LocalStorageOptions, 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, QueryError, type RawDocument, type ReadOptions, type RelationshipField, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoutesConfig, SESSION_COOKIE, 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, type Where, type WhereOperators, anyone, applyDefaults, consoleLogger, createEasyCMS, createRestHandler, defineConfig, fillMissing, findConfigFile, generateSlugs, generateTypes, hashPassword, imageDimensions, importConfig, isAdmin, isLoggedIn, loadConfig, localStorage, mergeForUpdate, mimeAllowed, parseId, populate, readCookie, resolveConfig, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword };
|
package/dist/index.js
CHANGED
|
@@ -160,7 +160,12 @@ var USERS = "users";
|
|
|
160
160
|
var MEDIA = "media";
|
|
161
161
|
var SESSIONS = "sessions";
|
|
162
162
|
var LOGIN_ATTEMPTS = "login-attempts";
|
|
163
|
-
var
|
|
163
|
+
var VERSIONS = "document-versions";
|
|
164
|
+
var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
|
|
165
|
+
SESSIONS,
|
|
166
|
+
LOGIN_ATTEMPTS,
|
|
167
|
+
VERSIONS
|
|
168
|
+
]);
|
|
164
169
|
var DEFAULT_ROLES = ["admin", "editor"];
|
|
165
170
|
var adminOrSelf = ({ user }) => {
|
|
166
171
|
if (!user) return false;
|
|
@@ -325,6 +330,21 @@ var internalCollections = [
|
|
|
325
330
|
fields: [{ name: "key", type: "text", required: true, index: true }]
|
|
326
331
|
}
|
|
327
332
|
];
|
|
333
|
+
var versionsCollection = {
|
|
334
|
+
slug: VERSIONS,
|
|
335
|
+
access: { read: nobody, create: nobody, update: nobody, delete: nobody },
|
|
336
|
+
fields: [
|
|
337
|
+
// Collection slug, or `global:<slug>`.
|
|
338
|
+
{ name: "parent", type: "text", required: true, index: true },
|
|
339
|
+
// Document id; 0 for globals.
|
|
340
|
+
{ name: "doc", type: "number", required: true, index: true },
|
|
341
|
+
{ name: "status", type: "text" },
|
|
342
|
+
// The newest version of the document: its current editable state.
|
|
343
|
+
{ name: "latest", type: "boolean", index: true },
|
|
344
|
+
{ name: "author", type: "number" },
|
|
345
|
+
{ name: "snapshot", type: "json", required: true }
|
|
346
|
+
]
|
|
347
|
+
};
|
|
328
348
|
|
|
329
349
|
// src/auth/password.ts
|
|
330
350
|
import { randomBytes, scrypt, timingSafeEqual } from "crypto";
|
|
@@ -847,6 +867,7 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set([
|
|
|
847
867
|
"globals",
|
|
848
868
|
"sessions",
|
|
849
869
|
"login-attempts",
|
|
870
|
+
"document-versions",
|
|
850
871
|
"migrations",
|
|
851
872
|
"access"
|
|
852
873
|
]);
|
|
@@ -867,6 +888,7 @@ function validateConfig(config) {
|
|
|
867
888
|
validateAdmin(config, add);
|
|
868
889
|
validateUpload(config, add);
|
|
869
890
|
validateAuth(config, add);
|
|
891
|
+
validateCors(config.cors, add);
|
|
870
892
|
const collections = asArray(config.collections, "collections", add);
|
|
871
893
|
const globals = asArray(config.globals, "globals", add);
|
|
872
894
|
const collectionSlugs = /* @__PURE__ */ new Set([...BUILTIN_COLLECTIONS]);
|
|
@@ -943,6 +965,21 @@ function validateAuth(config, add) {
|
|
|
943
965
|
}
|
|
944
966
|
}
|
|
945
967
|
}
|
|
968
|
+
function validateCors(cors, add) {
|
|
969
|
+
if (cors === void 0 || cors === "*") return;
|
|
970
|
+
if (!Array.isArray(cors)) {
|
|
971
|
+
add("cors", 'must be a list of origins or "*"', 'cors: ["https://my-site.com"]');
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
for (const [i, origin] of cors.entries()) {
|
|
975
|
+
if (typeof origin !== "string" || !/^https?:\/\/[^/]+$/.test(origin)) {
|
|
976
|
+
add(
|
|
977
|
+
`cors[${i}]`,
|
|
978
|
+
`must be an origin like "https://example.com" (got ${JSON.stringify(origin)})`
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
946
983
|
function validateUpload(config, add) {
|
|
947
984
|
const upload = config.upload;
|
|
948
985
|
if (upload === void 0) return;
|
|
@@ -986,6 +1023,15 @@ function validateContainer(container, path, collectionSlugs, add) {
|
|
|
986
1023
|
add(`${path}.fields`, "must be an array");
|
|
987
1024
|
return;
|
|
988
1025
|
}
|
|
1026
|
+
const versions = container.versions;
|
|
1027
|
+
if (versions !== void 0 && typeof versions !== "boolean") {
|
|
1028
|
+
const max = typeof versions === "object" && versions !== null ? versions.max : void 0;
|
|
1029
|
+
if (typeof versions !== "object" || versions === null) {
|
|
1030
|
+
add(`${path}.versions`, "must be true, false or { max }");
|
|
1031
|
+
} else if (max !== void 0 && (!Number.isInteger(max) || max < 1)) {
|
|
1032
|
+
add(`${path}.versions.max`, "must be a positive integer");
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
989
1035
|
const reserved = new Set(SYSTEM_FIELD_NAMES);
|
|
990
1036
|
if (container.drafts) reserved.add("status");
|
|
991
1037
|
validateFields2(container.fields, `${path}.fields`, reserved, collectionSlugs, add);
|
|
@@ -1125,6 +1171,7 @@ async function resolveConfig(input) {
|
|
|
1125
1171
|
const { plugins: _plugins, ...rest } = config;
|
|
1126
1172
|
const result = {
|
|
1127
1173
|
...rest,
|
|
1174
|
+
cors: config.cors ?? [],
|
|
1128
1175
|
routes: { api: `/${(config.routes?.api ?? DEFAULT_API_PATH).replace(/^\/+|\/+$/g, "")}` },
|
|
1129
1176
|
admin: {
|
|
1130
1177
|
path: config.admin?.path ?? DEFAULT_ADMIN_PATH,
|
|
@@ -1144,12 +1191,19 @@ async function resolveConfig(input) {
|
|
|
1144
1191
|
lockWindow: config.auth?.lockWindow ?? 15 * 60,
|
|
1145
1192
|
trustedOrigins: config.auth?.trustedOrigins ?? []
|
|
1146
1193
|
},
|
|
1147
|
-
collections: [
|
|
1194
|
+
collections: [
|
|
1195
|
+
...config.collections ?? [],
|
|
1196
|
+
...internalCollections,
|
|
1197
|
+
...usesVersions(config) ? [versionsCollection] : []
|
|
1198
|
+
],
|
|
1148
1199
|
globals: config.globals ?? []
|
|
1149
1200
|
};
|
|
1150
1201
|
resolved.add(result);
|
|
1151
1202
|
return result;
|
|
1152
1203
|
}
|
|
1204
|
+
function usesVersions(config) {
|
|
1205
|
+
return [...config.collections ?? [], ...config.globals ?? []].some((c) => c.versions);
|
|
1206
|
+
}
|
|
1153
1207
|
|
|
1154
1208
|
// src/load-config.ts
|
|
1155
1209
|
var CONFIG_FILE_NAMES = [
|
|
@@ -1412,6 +1466,137 @@ function localStorage(options = {}) {
|
|
|
1412
1466
|
};
|
|
1413
1467
|
}
|
|
1414
1468
|
|
|
1469
|
+
// src/versions.ts
|
|
1470
|
+
var DEFAULT_MAX_VERSIONS = 50;
|
|
1471
|
+
function versionLimit(config) {
|
|
1472
|
+
const versions = config.versions;
|
|
1473
|
+
if (!versions) return void 0;
|
|
1474
|
+
return versions === true ? DEFAULT_MAX_VERSIONS : versions.max ?? DEFAULT_MAX_VERSIONS;
|
|
1475
|
+
}
|
|
1476
|
+
var collectionParent = (slug) => slug;
|
|
1477
|
+
var globalParent = (slug) => `global:${slug}`;
|
|
1478
|
+
function snapshotOf(fields, doc) {
|
|
1479
|
+
const hidden = new Set(fields.filter((f) => f.hidden).map((f) => f.name));
|
|
1480
|
+
const snapshot = {};
|
|
1481
|
+
for (const [key2, value] of Object.entries(doc)) {
|
|
1482
|
+
if (key2 === "id" || key2 === "createdAt" || key2 === "updatedAt" || hidden.has(key2)) continue;
|
|
1483
|
+
snapshot[key2] = value;
|
|
1484
|
+
}
|
|
1485
|
+
return snapshot;
|
|
1486
|
+
}
|
|
1487
|
+
var toSummary = (row) => ({
|
|
1488
|
+
id: row.id,
|
|
1489
|
+
status: row.status ?? null,
|
|
1490
|
+
latest: row.latest === true,
|
|
1491
|
+
author: row.author ?? null,
|
|
1492
|
+
createdAt: String(row.createdAt)
|
|
1493
|
+
});
|
|
1494
|
+
var VersionStore = class {
|
|
1495
|
+
constructor(db) {
|
|
1496
|
+
this.db = db;
|
|
1497
|
+
}
|
|
1498
|
+
db;
|
|
1499
|
+
/** Saves a new latest version and deletes the oldest beyond `max`. */
|
|
1500
|
+
async save(args) {
|
|
1501
|
+
const { parent, doc } = args;
|
|
1502
|
+
const previous = await this.db.find({
|
|
1503
|
+
collection: VERSIONS,
|
|
1504
|
+
where: { and: [...this.of(parent, doc), { latest: { equals: true } }] },
|
|
1505
|
+
sort: [],
|
|
1506
|
+
limit: 0,
|
|
1507
|
+
page: 1
|
|
1508
|
+
});
|
|
1509
|
+
for (const row of previous.docs) {
|
|
1510
|
+
await this.db.update({ collection: VERSIONS, id: row.id, data: { ...row, latest: false } });
|
|
1511
|
+
}
|
|
1512
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1513
|
+
const created = await this.db.create({
|
|
1514
|
+
collection: VERSIONS,
|
|
1515
|
+
data: {
|
|
1516
|
+
parent,
|
|
1517
|
+
doc,
|
|
1518
|
+
status: args.status,
|
|
1519
|
+
latest: true,
|
|
1520
|
+
author: args.author,
|
|
1521
|
+
snapshot: args.snapshot,
|
|
1522
|
+
createdAt: now,
|
|
1523
|
+
updatedAt: now
|
|
1524
|
+
}
|
|
1525
|
+
});
|
|
1526
|
+
await this.prune(parent, doc, args.max);
|
|
1527
|
+
return toSummary(created);
|
|
1528
|
+
}
|
|
1529
|
+
/** The latest version of each document, keyed by document id. */
|
|
1530
|
+
async latest(parent, docs) {
|
|
1531
|
+
const result = /* @__PURE__ */ new Map();
|
|
1532
|
+
if (docs.length === 0) return result;
|
|
1533
|
+
const rows = await this.db.find({
|
|
1534
|
+
collection: VERSIONS,
|
|
1535
|
+
where: {
|
|
1536
|
+
and: [
|
|
1537
|
+
{ parent: { equals: parent } },
|
|
1538
|
+
{ doc: { in: [...docs] } },
|
|
1539
|
+
{ latest: { equals: true } }
|
|
1540
|
+
]
|
|
1541
|
+
},
|
|
1542
|
+
sort: [],
|
|
1543
|
+
limit: 0,
|
|
1544
|
+
page: 1
|
|
1545
|
+
});
|
|
1546
|
+
for (const row of rows.docs) result.set(String(row.doc), this.toVersion(row));
|
|
1547
|
+
return result;
|
|
1548
|
+
}
|
|
1549
|
+
async list(parent, doc, limit, page) {
|
|
1550
|
+
const found = await this.db.find({
|
|
1551
|
+
collection: VERSIONS,
|
|
1552
|
+
where: { and: this.of(parent, doc) },
|
|
1553
|
+
sort: ["-id"],
|
|
1554
|
+
limit,
|
|
1555
|
+
page
|
|
1556
|
+
});
|
|
1557
|
+
return { ...found, docs: found.docs.map(toSummary) };
|
|
1558
|
+
}
|
|
1559
|
+
async get(parent, doc, id) {
|
|
1560
|
+
const found = await this.db.find({
|
|
1561
|
+
collection: VERSIONS,
|
|
1562
|
+
where: { and: [...this.of(parent, doc), { id: { equals: id } }] },
|
|
1563
|
+
sort: [],
|
|
1564
|
+
limit: 1,
|
|
1565
|
+
page: 1
|
|
1566
|
+
});
|
|
1567
|
+
const row = found.docs[0];
|
|
1568
|
+
return row ? this.toVersion(row) : null;
|
|
1569
|
+
}
|
|
1570
|
+
async deleteAll(parent, doc) {
|
|
1571
|
+
const rows = await this.db.find({
|
|
1572
|
+
collection: VERSIONS,
|
|
1573
|
+
where: { and: this.of(parent, doc) },
|
|
1574
|
+
sort: [],
|
|
1575
|
+
limit: 0,
|
|
1576
|
+
page: 1
|
|
1577
|
+
});
|
|
1578
|
+
for (const row of rows.docs) await this.db.delete({ collection: VERSIONS, id: row.id });
|
|
1579
|
+
}
|
|
1580
|
+
async prune(parent, doc, max) {
|
|
1581
|
+
const rows = await this.db.find({
|
|
1582
|
+
collection: VERSIONS,
|
|
1583
|
+
where: { and: this.of(parent, doc) },
|
|
1584
|
+
sort: ["-id"],
|
|
1585
|
+
limit: 0,
|
|
1586
|
+
page: 1
|
|
1587
|
+
});
|
|
1588
|
+
for (const row of rows.docs.slice(max)) {
|
|
1589
|
+
await this.db.delete({ collection: VERSIONS, id: row.id });
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
of(parent, doc) {
|
|
1593
|
+
return [{ parent: { equals: parent } }, { doc: { equals: doc } }];
|
|
1594
|
+
}
|
|
1595
|
+
toVersion(row) {
|
|
1596
|
+
return { ...toSummary(row), data: row.snapshot ?? {} };
|
|
1597
|
+
}
|
|
1598
|
+
};
|
|
1599
|
+
|
|
1415
1600
|
// src/local-api.ts
|
|
1416
1601
|
async function createEasyCMS(config, options = {}) {
|
|
1417
1602
|
const resolved2 = await resolveConfig(config);
|
|
@@ -1436,11 +1621,13 @@ var EasyCMS = class {
|
|
|
1436
1621
|
auth;
|
|
1437
1622
|
/** Where uploaded files are stored. */
|
|
1438
1623
|
storage;
|
|
1624
|
+
versions;
|
|
1439
1625
|
constructor(config, db, logger = consoleLogger, storage = localStorage({ dir: config.upload.dir })) {
|
|
1440
1626
|
this.config = config;
|
|
1441
1627
|
this.db = db;
|
|
1442
1628
|
this.logger = logger;
|
|
1443
1629
|
this.storage = storage;
|
|
1630
|
+
this.versions = new VersionStore(db);
|
|
1444
1631
|
this.auth = new Auth(this);
|
|
1445
1632
|
}
|
|
1446
1633
|
async find(collection2, options = {}) {
|
|
@@ -1458,7 +1645,8 @@ var EasyCMS = class {
|
|
|
1458
1645
|
);
|
|
1459
1646
|
const sort = options.sort === void 0 ? ["-createdAt"] : [options.sort].flat();
|
|
1460
1647
|
const result = await this.db.find({ collection: collection2, where, sort, limit, page });
|
|
1461
|
-
const
|
|
1648
|
+
const found = options.draft ? await this.withDrafts(config, result.docs) : result.docs;
|
|
1649
|
+
const docs = await this.output(config, found, guard, options);
|
|
1462
1650
|
return { ...result, docs };
|
|
1463
1651
|
}
|
|
1464
1652
|
async findById(collection2, id, options = {}) {
|
|
@@ -1475,7 +1663,8 @@ var EasyCMS = class {
|
|
|
1475
1663
|
page: 1
|
|
1476
1664
|
})).docs[0] : await this.db.findById({ collection: collection2, id: parsed });
|
|
1477
1665
|
if (!doc) return null;
|
|
1478
|
-
const [
|
|
1666
|
+
const [current] = options.draft ? await this.withDrafts(config, [doc]) : [doc];
|
|
1667
|
+
const [out] = await this.output(config, [current], guard, options);
|
|
1479
1668
|
return out ?? null;
|
|
1480
1669
|
}
|
|
1481
1670
|
async count(collection2, options = {}) {
|
|
@@ -1560,27 +1749,113 @@ var EasyCMS = class {
|
|
|
1560
1749
|
return `${this.config.serverURL?.replace(/\/+$/, "") ?? ""}${this.config.routes.api}/media/file/${encodeURIComponent(key2)}`;
|
|
1561
1750
|
}
|
|
1562
1751
|
async update(collection2, id, data, options = {}) {
|
|
1752
|
+
return await this.updateDocument(collection2, id, asObject(data, collection2), options);
|
|
1753
|
+
}
|
|
1754
|
+
/**
|
|
1755
|
+
* Takes a document off the site: its status becomes `draft`. With versions and drafts, a plain
|
|
1756
|
+
* `update` with `status: 'draft'` only saves a draft and leaves the published document live;
|
|
1757
|
+
* this is how to unpublish it.
|
|
1758
|
+
*/
|
|
1759
|
+
async unpublish(collection2, id, options = {}) {
|
|
1760
|
+
const config = this.collection(collection2);
|
|
1761
|
+
if (!config.drafts) throw new QueryError(`"${collection2}" has no drafts`);
|
|
1762
|
+
return await this.updateDocument(
|
|
1763
|
+
collection2,
|
|
1764
|
+
id,
|
|
1765
|
+
{ status: "draft" },
|
|
1766
|
+
options,
|
|
1767
|
+
"unpublish"
|
|
1768
|
+
);
|
|
1769
|
+
}
|
|
1770
|
+
/** Throws away the unpublished draft of a published document, going back to what is live. */
|
|
1771
|
+
async discardDraft(collection2, id, options = {}) {
|
|
1772
|
+
const config = this.collection(collection2);
|
|
1773
|
+
const guard = guardOf(options);
|
|
1774
|
+
const parsed = parseId(id);
|
|
1775
|
+
const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
|
|
1776
|
+
if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
|
|
1777
|
+
await this.checkDocumentAccess(config, "update", guard, parsed, void 0);
|
|
1778
|
+
const [current] = await this.withDrafts(config, [existing]);
|
|
1779
|
+
if (current !== existing) {
|
|
1780
|
+
await this.saveVersion(config, collectionParent(collection2), parsed, existing, guard);
|
|
1781
|
+
}
|
|
1782
|
+
const [out] = await this.output(config, [existing], guard, { ...options, draft: true });
|
|
1783
|
+
return out;
|
|
1784
|
+
}
|
|
1785
|
+
/** Saved versions of a document, newest first. Needs update access to the document. */
|
|
1786
|
+
async findVersions(collection2, id, options = {}) {
|
|
1787
|
+
const { config, parsed } = await this.versionTarget(collection2, id, options);
|
|
1788
|
+
return this.versions.list(
|
|
1789
|
+
collectionParent(config.slug),
|
|
1790
|
+
parsed,
|
|
1791
|
+
options.limit ?? 20,
|
|
1792
|
+
options.page ?? 1
|
|
1793
|
+
);
|
|
1794
|
+
}
|
|
1795
|
+
/** One saved version with the document as it was then. */
|
|
1796
|
+
async findVersion(collection2, id, versionId, options = {}) {
|
|
1797
|
+
const { config, parsed, existing } = await this.versionTarget(collection2, id, options);
|
|
1798
|
+
const version = await this.versions.get(collectionParent(config.slug), parsed, versionId);
|
|
1799
|
+
if (!version) return null;
|
|
1800
|
+
const doc = {
|
|
1801
|
+
...version.data,
|
|
1802
|
+
id: parsed,
|
|
1803
|
+
createdAt: existing.createdAt,
|
|
1804
|
+
updatedAt: version.createdAt
|
|
1805
|
+
};
|
|
1806
|
+
const [out] = await this.output(config, [doc], guardOf(options), {
|
|
1807
|
+
...options,
|
|
1808
|
+
draft: true
|
|
1809
|
+
});
|
|
1810
|
+
return { ...version, data: out };
|
|
1811
|
+
}
|
|
1812
|
+
/**
|
|
1813
|
+
* Makes an old version the current content, as a new version. With drafts it is restored as
|
|
1814
|
+
* a draft (publish it to put it live); access, hooks and validation run as for `update`.
|
|
1815
|
+
*/
|
|
1816
|
+
async restoreVersion(collection2, id, versionId, options = {}) {
|
|
1817
|
+
const { config, parsed } = await this.versionTarget(collection2, id, options);
|
|
1818
|
+
const version = await this.versions.get(collectionParent(config.slug), parsed, versionId);
|
|
1819
|
+
if (!version) throw new NotFoundError(`${collection2} version`, versionId);
|
|
1820
|
+
const { status: _status, ...data } = version.data;
|
|
1821
|
+
return await this.updateDocument(
|
|
1822
|
+
collection2,
|
|
1823
|
+
parsed,
|
|
1824
|
+
config.drafts ? { ...data, status: "draft" } : data,
|
|
1825
|
+
options
|
|
1826
|
+
);
|
|
1827
|
+
}
|
|
1828
|
+
async versionTarget(collection2, id, options) {
|
|
1829
|
+
const config = this.collection(collection2);
|
|
1830
|
+
if (!versionLimit(config)) throw new QueryError(`"${collection2}" has no versions`);
|
|
1831
|
+
const parsed = parseId(id);
|
|
1832
|
+
const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
|
|
1833
|
+
if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
|
|
1834
|
+
await this.checkDocumentAccess(config, "update", guardOf(options), parsed, void 0);
|
|
1835
|
+
return { config, parsed, existing };
|
|
1836
|
+
}
|
|
1837
|
+
async updateDocument(collection2, id, raw, options, mode = "save") {
|
|
1563
1838
|
const config = this.collection(collection2);
|
|
1564
1839
|
const guard = guardOf(options);
|
|
1565
|
-
const raw = asObject(data, collection2);
|
|
1566
1840
|
const parsed = parseId(id);
|
|
1567
1841
|
const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
|
|
1568
1842
|
if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
|
|
1569
1843
|
await this.checkDocumentAccess(config, "update", guard, parsed, raw);
|
|
1844
|
+
const [current] = await this.withDrafts(config, [existing]);
|
|
1570
1845
|
const { input, password } = splitPassword(config, raw);
|
|
1571
1846
|
const filtered = await filterInput(
|
|
1572
1847
|
config.fields,
|
|
1573
1848
|
input,
|
|
1574
1849
|
this.fieldChecker("update", guard, parsed, input)
|
|
1575
1850
|
);
|
|
1576
|
-
let merged = mergeForUpdate(config.fields,
|
|
1851
|
+
let merged = mergeForUpdate(config.fields, current, filtered);
|
|
1577
1852
|
if (config.drafts)
|
|
1578
|
-
merged.status = Object.hasOwn(filtered, "status") ? filtered.status :
|
|
1853
|
+
merged.status = Object.hasOwn(filtered, "status") ? filtered.status : current.status;
|
|
1579
1854
|
const base = this.hookArgs(config, guard);
|
|
1580
1855
|
merged = await this.transform(
|
|
1581
1856
|
config.hooks?.beforeValidate,
|
|
1582
1857
|
"data",
|
|
1583
|
-
{ ...base, operation: "update", originalDoc:
|
|
1858
|
+
{ ...base, operation: "update", originalDoc: current },
|
|
1584
1859
|
merged
|
|
1585
1860
|
);
|
|
1586
1861
|
let prepared = await this.prepare(
|
|
@@ -1592,21 +1867,43 @@ var EasyCMS = class {
|
|
|
1592
1867
|
prepared = await this.transform(
|
|
1593
1868
|
config.hooks?.beforeChange,
|
|
1594
1869
|
"data",
|
|
1595
|
-
{ ...base, operation: "update", originalDoc:
|
|
1870
|
+
{ ...base, operation: "update", originalDoc: current },
|
|
1596
1871
|
prepared
|
|
1597
1872
|
);
|
|
1873
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1874
|
+
if (mode === "save" && this.separateDrafts(config) && existing.status === "published" && prepared.status === "draft") {
|
|
1875
|
+
if (password !== void 0) {
|
|
1876
|
+
throw new ValidationError(collection2, [
|
|
1877
|
+
{ field: "password", message: "cannot be changed in a draft; publish instead" }
|
|
1878
|
+
]);
|
|
1879
|
+
}
|
|
1880
|
+
const draft = { ...prepared, id: parsed, createdAt: existing.createdAt, updatedAt: now };
|
|
1881
|
+
await this.saveVersion(config, collectionParent(collection2), parsed, draft, guard);
|
|
1882
|
+
await this.notify(config.hooks?.afterChange, "afterChange", config.slug, {
|
|
1883
|
+
...base,
|
|
1884
|
+
doc: draft,
|
|
1885
|
+
previousDoc: current,
|
|
1886
|
+
operation: "update"
|
|
1887
|
+
});
|
|
1888
|
+
const [out2] = await this.output(config, [draft], guard, {
|
|
1889
|
+
...options,
|
|
1890
|
+
draft: true
|
|
1891
|
+
});
|
|
1892
|
+
return out2;
|
|
1893
|
+
}
|
|
1598
1894
|
if (config.slug === USERS) await this.guardLastAdmin(parsed, existing, prepared);
|
|
1599
1895
|
if (password !== void 0) prepared.passwordHash = await hashPassword(password);
|
|
1600
1896
|
const doc = await this.db.update({
|
|
1601
1897
|
collection: collection2,
|
|
1602
1898
|
id: parsed,
|
|
1603
|
-
data: { ...prepared, createdAt: existing.createdAt, updatedAt:
|
|
1899
|
+
data: { ...prepared, createdAt: existing.createdAt, updatedAt: now }
|
|
1604
1900
|
});
|
|
1901
|
+
await this.saveVersion(config, collectionParent(collection2), parsed, doc, guard);
|
|
1605
1902
|
if (password !== void 0) await this.auth.revokeSessions(parsed);
|
|
1606
1903
|
await this.notify(config.hooks?.afterChange, "afterChange", config.slug, {
|
|
1607
1904
|
...base,
|
|
1608
1905
|
doc,
|
|
1609
|
-
previousDoc:
|
|
1906
|
+
previousDoc: current,
|
|
1610
1907
|
operation: "update"
|
|
1611
1908
|
});
|
|
1612
1909
|
const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
|
|
@@ -1625,6 +1922,7 @@ var EasyCMS = class {
|
|
|
1625
1922
|
for (const hook of config.hooks?.beforeDelete ?? []) await hook({ ...base, id: parsed });
|
|
1626
1923
|
if (config.slug === USERS) await this.auth.revokeSessions(parsed);
|
|
1627
1924
|
await this.db.delete({ collection: collection2, id: parsed });
|
|
1925
|
+
if (versionLimit(config)) await this.versions.deleteAll(collectionParent(collection2), parsed);
|
|
1628
1926
|
await this.notify(config.hooks?.afterDelete, "afterDelete", config.slug, {
|
|
1629
1927
|
...base,
|
|
1630
1928
|
id: parsed,
|
|
@@ -1637,7 +1935,8 @@ var EasyCMS = class {
|
|
|
1637
1935
|
const config = this.global(slug);
|
|
1638
1936
|
const guard = guardOf(options);
|
|
1639
1937
|
await this.checkGlobalAccess(config, "read", guard);
|
|
1640
|
-
const
|
|
1938
|
+
const saved = await this.db.findGlobal({ slug });
|
|
1939
|
+
const stored = saved && options.draft ? await this.globalDraft(config, saved) : saved;
|
|
1641
1940
|
const data = fillMissing(config.fields, applyDefaults(config.fields, stored ?? {}));
|
|
1642
1941
|
if (!stored) {
|
|
1643
1942
|
data.updatedAt = null;
|
|
@@ -1648,37 +1947,108 @@ var EasyCMS = class {
|
|
|
1648
1947
|
return doc;
|
|
1649
1948
|
}
|
|
1650
1949
|
async updateGlobal(slug, data, options = {}) {
|
|
1950
|
+
return this.saveGlobal(slug, asObject(data, slug), options);
|
|
1951
|
+
}
|
|
1952
|
+
/** Takes a global off the site (`status: 'draft'`). See `unpublish`. */
|
|
1953
|
+
async unpublishGlobal(slug, options = {}) {
|
|
1954
|
+
if (!this.global(slug).drafts) throw new QueryError(`"${slug}" has no drafts`);
|
|
1955
|
+
return this.saveGlobal(slug, { status: "draft" }, options, "unpublish");
|
|
1956
|
+
}
|
|
1957
|
+
/** Throws away the unpublished draft of a published global. */
|
|
1958
|
+
async discardGlobalDraft(slug, options = {}) {
|
|
1959
|
+
const config = this.global(slug);
|
|
1960
|
+
const guard = guardOf(options);
|
|
1961
|
+
await this.checkGlobalAccess(config, "update", guard);
|
|
1962
|
+
const saved = await this.db.findGlobal({ slug });
|
|
1963
|
+
if (saved && await this.globalDraft(config, saved) !== saved) {
|
|
1964
|
+
await this.saveVersion(config, globalParent(slug), 0, saved, guard);
|
|
1965
|
+
}
|
|
1966
|
+
return this.findGlobal(slug, { ...options, draft: true });
|
|
1967
|
+
}
|
|
1968
|
+
/** Saved versions of a global, newest first. Needs update access. */
|
|
1969
|
+
async findGlobalVersions(slug, options = {}) {
|
|
1970
|
+
await this.globalVersionTarget(slug, options);
|
|
1971
|
+
return this.versions.list(globalParent(slug), 0, options.limit ?? 20, options.page ?? 1);
|
|
1972
|
+
}
|
|
1973
|
+
async findGlobalVersion(slug, versionId, options = {}) {
|
|
1974
|
+
const config = await this.globalVersionTarget(slug, options);
|
|
1975
|
+
const version = await this.versions.get(globalParent(slug), 0, versionId);
|
|
1976
|
+
if (!version) return null;
|
|
1977
|
+
const data = fillMissing(config.fields, applyDefaults(config.fields, version.data));
|
|
1978
|
+
const [out] = await this.output(config, [{ ...data, id: 0 }], guardOf(options), {
|
|
1979
|
+
...options,
|
|
1980
|
+
draft: true
|
|
1981
|
+
});
|
|
1982
|
+
const { id: _id, ...doc } = out;
|
|
1983
|
+
return { ...version, data: { ...doc, updatedAt: version.createdAt } };
|
|
1984
|
+
}
|
|
1985
|
+
/** Makes an old version of a global current (as a draft when the global has drafts). */
|
|
1986
|
+
async restoreGlobalVersion(slug, versionId, options = {}) {
|
|
1987
|
+
const config = await this.globalVersionTarget(slug, options);
|
|
1988
|
+
const version = await this.versions.get(globalParent(slug), 0, versionId);
|
|
1989
|
+
if (!version) throw new NotFoundError(`${slug} version`, versionId);
|
|
1990
|
+
const { status: _status, ...data } = version.data;
|
|
1991
|
+
return this.saveGlobal(
|
|
1992
|
+
slug,
|
|
1993
|
+
config.drafts ? { ...data, status: "draft" } : data,
|
|
1994
|
+
options
|
|
1995
|
+
);
|
|
1996
|
+
}
|
|
1997
|
+
async globalVersionTarget(slug, options) {
|
|
1998
|
+
const config = this.global(slug);
|
|
1999
|
+
if (!versionLimit(config)) throw new QueryError(`"${slug}" has no versions`);
|
|
2000
|
+
await this.checkGlobalAccess(config, "update", guardOf(options));
|
|
2001
|
+
return config;
|
|
2002
|
+
}
|
|
2003
|
+
/** The global's pending draft when it has one (see `withDrafts`), otherwise `saved`. */
|
|
2004
|
+
async globalDraft(config, saved) {
|
|
2005
|
+
if (!this.separateDrafts(config) || saved.status !== "published") return saved;
|
|
2006
|
+
const version = (await this.versions.latest(globalParent(config.slug), [0])).get("0");
|
|
2007
|
+
if (version?.status !== "draft") return saved;
|
|
2008
|
+
return { ...version.data, updatedAt: version.createdAt, status: "draft" };
|
|
2009
|
+
}
|
|
2010
|
+
async saveGlobal(slug, raw, options, mode = "save") {
|
|
1651
2011
|
const config = this.global(slug);
|
|
1652
2012
|
const guard = guardOf(options);
|
|
1653
2013
|
await this.checkGlobalAccess(config, "update", guard);
|
|
1654
|
-
const raw = asObject(data, slug);
|
|
1655
2014
|
const input = await filterInput(
|
|
1656
2015
|
config.fields,
|
|
1657
2016
|
raw,
|
|
1658
2017
|
this.fieldChecker("update", guard, void 0, raw)
|
|
1659
2018
|
);
|
|
1660
2019
|
const existing = await this.db.findGlobal({ slug }) ?? {};
|
|
2020
|
+
const current = await this.globalDraft(config, existing);
|
|
1661
2021
|
const merged = generateSlugs(
|
|
1662
2022
|
config.fields,
|
|
1663
|
-
applyDefaults(config.fields, mergeForUpdate(config.fields,
|
|
2023
|
+
applyDefaults(config.fields, mergeForUpdate(config.fields, current, input))
|
|
1664
2024
|
);
|
|
1665
|
-
if (config.drafts) merged.status = input.status ??
|
|
2025
|
+
if (config.drafts) merged.status = input.status ?? current.status ?? "draft";
|
|
1666
2026
|
const base = this.hookArgs(config, guard);
|
|
1667
2027
|
let prepared = await this.prepare(config, merged, "update", void 0);
|
|
1668
2028
|
prepared = await this.transform(
|
|
1669
2029
|
config.hooks?.beforeChange,
|
|
1670
2030
|
"data",
|
|
1671
|
-
{ ...base, operation: "update", originalDoc:
|
|
2031
|
+
{ ...base, operation: "update", originalDoc: current },
|
|
1672
2032
|
prepared
|
|
1673
2033
|
);
|
|
1674
|
-
const
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
2034
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2035
|
+
if (mode === "save" && this.separateDrafts(config) && existing.status === "published" && prepared.status === "draft") {
|
|
2036
|
+
const draft = { ...prepared, updatedAt: now };
|
|
2037
|
+
await this.saveVersion(config, globalParent(slug), 0, draft, guard);
|
|
2038
|
+
await this.notify(config.hooks?.afterChange, "afterChange", slug, {
|
|
2039
|
+
...base,
|
|
2040
|
+
doc: draft,
|
|
2041
|
+
previousDoc: current,
|
|
2042
|
+
operation: "update"
|
|
2043
|
+
});
|
|
2044
|
+
return this.findGlobal(slug, { ...options, draft: true });
|
|
2045
|
+
}
|
|
2046
|
+
const doc = await this.db.updateGlobal({ slug, data: { ...prepared, updatedAt: now } });
|
|
2047
|
+
await this.saveVersion(config, globalParent(slug), 0, doc, guard);
|
|
1678
2048
|
await this.notify(config.hooks?.afterChange, "afterChange", slug, {
|
|
1679
2049
|
...base,
|
|
1680
2050
|
doc,
|
|
1681
|
-
previousDoc:
|
|
2051
|
+
previousDoc: current,
|
|
1682
2052
|
operation: "update"
|
|
1683
2053
|
});
|
|
1684
2054
|
return this.findGlobal(slug, { ...options, draft: true });
|
|
@@ -1781,7 +2151,8 @@ var EasyCMS = class {
|
|
|
1781
2151
|
limit: 0,
|
|
1782
2152
|
page: 1
|
|
1783
2153
|
});
|
|
1784
|
-
|
|
2154
|
+
const docs2 = options.draft ? await this.withDrafts(target, found.docs) : found.docs;
|
|
2155
|
+
return Promise.all(docs2.map((d) => finish(target, d)));
|
|
1785
2156
|
};
|
|
1786
2157
|
const depth = Math.max(0, Math.min(MAX_DEPTH, Math.trunc(options.depth ?? DEFAULT_DEPTH)));
|
|
1787
2158
|
const populated = await populate(load, this.config.collections, config.fields, docs, depth);
|
|
@@ -1832,6 +2203,7 @@ var EasyCMS = class {
|
|
|
1832
2203
|
collection: collection2,
|
|
1833
2204
|
data: { ...prepared, createdAt: now, updatedAt: now }
|
|
1834
2205
|
});
|
|
2206
|
+
await this.saveVersion(config, collectionParent(collection2), doc.id, doc, guard);
|
|
1835
2207
|
await this.notify(config.hooks?.afterChange, "afterChange", collection2, {
|
|
1836
2208
|
...base,
|
|
1837
2209
|
doc,
|
|
@@ -1840,6 +2212,33 @@ var EasyCMS = class {
|
|
|
1840
2212
|
const [out] = await this.output(config, [doc], hookGuard ?? guard, { ...options, draft: true });
|
|
1841
2213
|
return out;
|
|
1842
2214
|
}
|
|
2215
|
+
/** Drafts are kept as versions (instead of unpublishing) when both are enabled. */
|
|
2216
|
+
separateDrafts(config) {
|
|
2217
|
+
return config.drafts === true && versionLimit(config) !== void 0;
|
|
2218
|
+
}
|
|
2219
|
+
/** Replaces published documents that have a newer draft with that draft. */
|
|
2220
|
+
async withDrafts(config, docs) {
|
|
2221
|
+
if (!this.separateDrafts(config)) return docs;
|
|
2222
|
+
const published = docs.filter((d) => d.status === "published").map((d) => d.id);
|
|
2223
|
+
const latest = await this.versions.latest(collectionParent(config.slug), published);
|
|
2224
|
+
return docs.map((doc) => {
|
|
2225
|
+
const version = latest.get(String(doc.id));
|
|
2226
|
+
return version && doc.status === "published" && version.status === "draft" ? draftOf(doc, version) : doc;
|
|
2227
|
+
});
|
|
2228
|
+
}
|
|
2229
|
+
/** Records a version after a save, when the collection or global keeps versions. */
|
|
2230
|
+
async saveVersion(config, parent, id, doc, guard) {
|
|
2231
|
+
const max = versionLimit(config);
|
|
2232
|
+
if (max === void 0) return;
|
|
2233
|
+
await this.versions.save({
|
|
2234
|
+
parent,
|
|
2235
|
+
doc: id,
|
|
2236
|
+
status: config.drafts ? doc.status ?? "draft" : null,
|
|
2237
|
+
snapshot: snapshotOf(config.fields, doc),
|
|
2238
|
+
author: guard.user?.id ?? null,
|
|
2239
|
+
max
|
|
2240
|
+
});
|
|
2241
|
+
}
|
|
1843
2242
|
hookArgs(config, guard) {
|
|
1844
2243
|
return { user: guard.user, cms: this, slug: config.slug };
|
|
1845
2244
|
}
|
|
@@ -1997,6 +2396,15 @@ var EasyCMS = class {
|
|
|
1997
2396
|
return errors;
|
|
1998
2397
|
}
|
|
1999
2398
|
};
|
|
2399
|
+
function draftOf(doc, version) {
|
|
2400
|
+
return {
|
|
2401
|
+
...version.data,
|
|
2402
|
+
id: doc.id,
|
|
2403
|
+
createdAt: doc.createdAt,
|
|
2404
|
+
updatedAt: version.createdAt,
|
|
2405
|
+
status: "draft"
|
|
2406
|
+
};
|
|
2407
|
+
}
|
|
2000
2408
|
function draftWhere(config, draft, where) {
|
|
2001
2409
|
if (!config.drafts || draft) return where;
|
|
2002
2410
|
return andWhere(where, { status: { equals: "published" } });
|
|
@@ -2090,6 +2498,7 @@ async function collection(config, user) {
|
|
|
2090
2498
|
const result = {
|
|
2091
2499
|
slug: config.slug,
|
|
2092
2500
|
drafts: config.drafts === true,
|
|
2501
|
+
versions: Boolean(config.versions),
|
|
2093
2502
|
fields: await serializeFields(config.fields, new FieldAccessChecker("update", { user })),
|
|
2094
2503
|
permissions: {
|
|
2095
2504
|
read: await allowed(config.access?.read, user),
|
|
@@ -2106,6 +2515,7 @@ async function global(config, user) {
|
|
|
2106
2515
|
const result = {
|
|
2107
2516
|
slug: config.slug,
|
|
2108
2517
|
drafts: config.drafts === true,
|
|
2518
|
+
versions: Boolean(config.versions),
|
|
2109
2519
|
fields: await serializeFields(config.fields, new FieldAccessChecker("update", { user })),
|
|
2110
2520
|
permissions: {
|
|
2111
2521
|
read: await allowed(config.access?.read, user),
|
|
@@ -2228,6 +2638,13 @@ function createRestHandler(instance, options = {}) {
|
|
|
2228
2638
|
const basePath = (options.basePath ?? cms.config.routes.api).replace(/\/+$/, "");
|
|
2229
2639
|
const production = process.env.NODE_ENV === "production";
|
|
2230
2640
|
return async (request) => {
|
|
2641
|
+
const cors = corsHeaders(cms, request);
|
|
2642
|
+
if (request.method.toUpperCase() === "OPTIONS") {
|
|
2643
|
+
return new Response(null, { status: 204, headers: cors });
|
|
2644
|
+
}
|
|
2645
|
+
return withHeaders(await handle(request), cors);
|
|
2646
|
+
};
|
|
2647
|
+
async function handle(request) {
|
|
2231
2648
|
const headers = new Headers({
|
|
2232
2649
|
"content-type": "application/json; charset=utf-8",
|
|
2233
2650
|
"cache-control": "no-store",
|
|
@@ -2243,14 +2660,44 @@ function createRestHandler(instance, options = {}) {
|
|
|
2243
2660
|
const user = token ? await cms.auth.verify(token) : null;
|
|
2244
2661
|
const ctx = { request, url, user, via: user ? via : null, token, headers };
|
|
2245
2662
|
const method = request.method.toUpperCase();
|
|
2246
|
-
if (method !== "GET" && method !== "HEAD"
|
|
2663
|
+
if (method !== "GET" && method !== "HEAD") checkCsrf(cms, ctx);
|
|
2247
2664
|
const result = await route(cms, ctx, method, segments, options);
|
|
2248
2665
|
if (result.body instanceof Response) return result.body;
|
|
2249
2666
|
return new Response(JSON.stringify(result.body), { status: result.status ?? 200, headers });
|
|
2250
2667
|
} catch (error) {
|
|
2251
2668
|
return errorResponse(cms, error, headers, production);
|
|
2252
2669
|
}
|
|
2253
|
-
}
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
var CORS_METHODS = "GET, HEAD, POST, PATCH, PUT, DELETE";
|
|
2673
|
+
var CORS_HEADERS = `authorization, content-type, ${CSRF_HEADER}`;
|
|
2674
|
+
function corsHeaders(cms, request) {
|
|
2675
|
+
const headers = new Headers({ vary: "Origin" });
|
|
2676
|
+
const origin = request.headers.get("origin");
|
|
2677
|
+
if (!origin) return headers;
|
|
2678
|
+
const { cors, auth } = cms.config;
|
|
2679
|
+
const trusted = auth.trustedOrigins.includes(origin);
|
|
2680
|
+
if (!trusted && cors !== "*" && !cors.includes(origin)) return headers;
|
|
2681
|
+
headers.set("access-control-allow-origin", origin);
|
|
2682
|
+
if (trusted) headers.set("access-control-allow-credentials", "true");
|
|
2683
|
+
if (request.method.toUpperCase() === "OPTIONS") {
|
|
2684
|
+
headers.set("access-control-allow-methods", CORS_METHODS);
|
|
2685
|
+
headers.set("access-control-allow-headers", CORS_HEADERS);
|
|
2686
|
+
headers.set("access-control-max-age", "600");
|
|
2687
|
+
}
|
|
2688
|
+
return headers;
|
|
2689
|
+
}
|
|
2690
|
+
function withHeaders(response, extra) {
|
|
2691
|
+
const headers = new Headers(response.headers);
|
|
2692
|
+
extra.forEach((value, key2) => {
|
|
2693
|
+
if (key2 === "vary") headers.append("vary", value);
|
|
2694
|
+
else headers.set(key2, value);
|
|
2695
|
+
});
|
|
2696
|
+
return new Response(response.body, {
|
|
2697
|
+
status: response.status,
|
|
2698
|
+
statusText: response.statusText,
|
|
2699
|
+
headers
|
|
2700
|
+
});
|
|
2254
2701
|
}
|
|
2255
2702
|
async function route(cms, ctx, method, segments, options) {
|
|
2256
2703
|
const [first, second, third] = segments;
|
|
@@ -2315,9 +2762,10 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
2315
2762
|
throw new HttpError("Not found", 404);
|
|
2316
2763
|
}
|
|
2317
2764
|
if (first === "globals") {
|
|
2318
|
-
if (!second
|
|
2765
|
+
if (!second) throw new HttpError("Not found", 404);
|
|
2319
2766
|
if (!cms.config.globals.some((g) => g.slug === second))
|
|
2320
2767
|
throw new HttpError(`Unknown global "${second}"`, 404);
|
|
2768
|
+
if (third !== void 0) return globalAction(cms, ctx, method, second, segments.slice(2));
|
|
2321
2769
|
if (method === "GET")
|
|
2322
2770
|
return { body: await cms.findGlobal(second, { ...access, ...parseDepth(ctx.url), draft }) };
|
|
2323
2771
|
if (method === "POST") {
|
|
@@ -2341,7 +2789,9 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
2341
2789
|
if (INTERNAL_COLLECTIONS.has(collection2) || !cms.config.collections.some((c) => c.slug === collection2)) {
|
|
2342
2790
|
throw new HttpError(`Unknown collection "${collection2}"`, 404);
|
|
2343
2791
|
}
|
|
2344
|
-
if (
|
|
2792
|
+
if (second !== void 0 && third !== void 0) {
|
|
2793
|
+
return documentAction(cms, ctx, method, collection2, second, segments.slice(2));
|
|
2794
|
+
}
|
|
2345
2795
|
if (second === void 0) {
|
|
2346
2796
|
if (method === "GET") {
|
|
2347
2797
|
const query = parseListQuery(ctx.url);
|
|
@@ -2375,6 +2825,64 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
2375
2825
|
}
|
|
2376
2826
|
throw methodNotAllowed(ctx, "GET, PATCH, DELETE");
|
|
2377
2827
|
}
|
|
2828
|
+
async function documentAction(cms, ctx, method, collection2, id, path) {
|
|
2829
|
+
const access = { overrideAccess: false, user: ctx.user };
|
|
2830
|
+
const depth = parseDepth(ctx.url);
|
|
2831
|
+
const [action, versionId, extra] = path;
|
|
2832
|
+
if (action === "versions" && versionId === void 0) {
|
|
2833
|
+
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
2834
|
+
return { body: await cms.findVersions(collection2, id, { ...access, ...parsePage(ctx.url) }) };
|
|
2835
|
+
}
|
|
2836
|
+
if (action === "versions" && versionId !== void 0 && extra === void 0) {
|
|
2837
|
+
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
2838
|
+
const version = await cms.findVersion(collection2, id, versionId, { ...access, ...depth });
|
|
2839
|
+
if (!version) throw new NotFoundError(`${collection2} version`, versionId);
|
|
2840
|
+
return { body: version };
|
|
2841
|
+
}
|
|
2842
|
+
if (action === "versions" && versionId !== void 0 && extra === "restore" && path.length === 3) {
|
|
2843
|
+
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
2844
|
+
return { body: await cms.restoreVersion(collection2, id, versionId, { ...access, ...depth }) };
|
|
2845
|
+
}
|
|
2846
|
+
if (path.length === 1 && (action === "unpublish" || action === "discard-draft")) {
|
|
2847
|
+
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
2848
|
+
const options = { ...access, ...depth };
|
|
2849
|
+
return {
|
|
2850
|
+
body: action === "unpublish" ? await cms.unpublish(collection2, id, options) : await cms.discardDraft(collection2, id, options)
|
|
2851
|
+
};
|
|
2852
|
+
}
|
|
2853
|
+
throw new HttpError("Not found", 404);
|
|
2854
|
+
}
|
|
2855
|
+
async function globalAction(cms, ctx, method, slug, path) {
|
|
2856
|
+
const access = { overrideAccess: false, user: ctx.user };
|
|
2857
|
+
const depth = parseDepth(ctx.url);
|
|
2858
|
+
const [action, versionId, extra] = path;
|
|
2859
|
+
if (action === "versions" && versionId === void 0) {
|
|
2860
|
+
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
2861
|
+
return { body: await cms.findGlobalVersions(slug, { ...access, ...parsePage(ctx.url) }) };
|
|
2862
|
+
}
|
|
2863
|
+
if (action === "versions" && versionId !== void 0 && extra === void 0) {
|
|
2864
|
+
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
2865
|
+
const version = await cms.findGlobalVersion(slug, versionId, { ...access, ...depth });
|
|
2866
|
+
if (!version) throw new NotFoundError(`${slug} version`, versionId);
|
|
2867
|
+
return { body: version };
|
|
2868
|
+
}
|
|
2869
|
+
if (action === "versions" && versionId !== void 0 && extra === "restore" && path.length === 3) {
|
|
2870
|
+
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
2871
|
+
return { body: await cms.restoreGlobalVersion(slug, versionId, { ...access, ...depth }) };
|
|
2872
|
+
}
|
|
2873
|
+
if (path.length === 1 && (action === "unpublish" || action === "discard-draft")) {
|
|
2874
|
+
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
2875
|
+
const options = { ...access, ...depth };
|
|
2876
|
+
return {
|
|
2877
|
+
body: action === "unpublish" ? await cms.unpublishGlobal(slug, options) : await cms.discardGlobalDraft(slug, options)
|
|
2878
|
+
};
|
|
2879
|
+
}
|
|
2880
|
+
throw new HttpError("Not found", 404);
|
|
2881
|
+
}
|
|
2882
|
+
function parsePage(url) {
|
|
2883
|
+
const { page, limit } = parseListQuery(url);
|
|
2884
|
+
return { page, limit };
|
|
2885
|
+
}
|
|
2378
2886
|
function methodNotAllowed(ctx, allow) {
|
|
2379
2887
|
ctx.headers.set("allow", allow);
|
|
2380
2888
|
return new HttpError("Method not allowed", 405);
|
|
@@ -2680,6 +3188,7 @@ export {
|
|
|
2680
3188
|
DEFAULT_API_PATH,
|
|
2681
3189
|
DEFAULT_DEPTH,
|
|
2682
3190
|
DEFAULT_MAX_FILE_SIZE,
|
|
3191
|
+
DEFAULT_MAX_VERSIONS,
|
|
2683
3192
|
DEFAULT_TOKEN_EXPIRATION,
|
|
2684
3193
|
EXTENSIONS,
|
|
2685
3194
|
EasyCMS,
|