@easy-cms/core 0.39.0 → 0.40.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 +132 -6
- package/dist/index.js +455 -28
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -538,6 +538,40 @@ interface BuiltinApiKeysCollection {
|
|
|
538
538
|
}
|
|
539
539
|
];
|
|
540
540
|
}
|
|
541
|
+
/** The built-in media folders (`upload.folders: true`). */
|
|
542
|
+
interface BuiltinMediaFoldersCollection {
|
|
543
|
+
readonly slug: 'media-folders';
|
|
544
|
+
readonly fields: readonly [
|
|
545
|
+
{
|
|
546
|
+
readonly name: 'name';
|
|
547
|
+
readonly type: 'text';
|
|
548
|
+
readonly required: true;
|
|
549
|
+
},
|
|
550
|
+
{
|
|
551
|
+
readonly name: 'parent';
|
|
552
|
+
readonly type: 'relationship';
|
|
553
|
+
readonly to: 'media-folders';
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
readonly name: 'key';
|
|
557
|
+
readonly type: 'text';
|
|
558
|
+
},
|
|
559
|
+
{
|
|
560
|
+
readonly name: 'private';
|
|
561
|
+
readonly type: 'boolean';
|
|
562
|
+
},
|
|
563
|
+
{
|
|
564
|
+
readonly name: 'permissions';
|
|
565
|
+
readonly type: 'json';
|
|
566
|
+
}
|
|
567
|
+
];
|
|
568
|
+
}
|
|
569
|
+
/** `media-folders` when the config turns folders on. */
|
|
570
|
+
type FoldersOf<C extends Config> = C extends {
|
|
571
|
+
readonly upload: {
|
|
572
|
+
readonly folders: true;
|
|
573
|
+
};
|
|
574
|
+
} ? BuiltinMediaFoldersCollection : never;
|
|
541
575
|
/** `api-keys` when the config turns API keys on. */
|
|
542
576
|
type ApiKeysOf<C extends Config> = C extends {
|
|
543
577
|
readonly apiKeys: true;
|
|
@@ -565,7 +599,7 @@ type WithPluginFields<C extends Config, T extends {
|
|
|
565
599
|
readonly fields: readonly (T['fields'][number] | PluginFields<C, T['slug'], Key>)[];
|
|
566
600
|
};
|
|
567
601
|
/** Every collection of a config, including the built-in ones and those plugins add. */
|
|
568
|
-
type AllCollections<C extends Config> = NonNullable<C['collections']>[number] | BuiltinUsersCollection | BuiltinMediaCollection | ApiKeysOf<C> | PluginCollections<C>;
|
|
602
|
+
type AllCollections<C extends Config> = NonNullable<C['collections']>[number] | BuiltinUsersCollection | BuiltinMediaCollection | ApiKeysOf<C> | FoldersOf<C> | PluginCollections<C>;
|
|
569
603
|
/** The built-in media collection; its documents are typed as `MediaDocument`. */
|
|
570
604
|
interface BuiltinMediaCollection {
|
|
571
605
|
readonly slug: 'media';
|
|
@@ -596,7 +630,11 @@ interface MediaDocument {
|
|
|
596
630
|
width?: number | null;
|
|
597
631
|
height?: number | null;
|
|
598
632
|
alt?: string | null;
|
|
599
|
-
/**
|
|
633
|
+
/** Its media folder (`upload.folders`), when it is in one. */
|
|
634
|
+
folder?: ID | null;
|
|
635
|
+
/** In a private folder: `url` needs a signed-in user who may see it, or `cms.signedMediaURL()`. */
|
|
636
|
+
private?: boolean | null;
|
|
637
|
+
/** URL of the file: public, or for private files the API's, which checks who asks. */
|
|
600
638
|
url: string;
|
|
601
639
|
/** Resized copies, when `upload.imageSizes` is set and sharp is installed. */
|
|
602
640
|
sizes: Record<string, MediaSize>;
|
|
@@ -703,7 +741,7 @@ type InferGlobal<T extends FieldsOwner, C extends Config = Config> = Simplify<
|
|
|
703
741
|
} ? {
|
|
704
742
|
status: 'draft' | 'published';
|
|
705
743
|
} : unknown) & FieldsValue<T['fields'], C>>;
|
|
706
|
-
type CollectionSlug<C extends Config> = NonNullable<C['collections']>[number]['slug'] | 'users' | 'media' | ApiKeysOf<C>['slug'] | PluginCollections<C>['slug'];
|
|
744
|
+
type CollectionSlug<C extends Config> = NonNullable<C['collections']>[number]['slug'] | 'users' | 'media' | ApiKeysOf<C>['slug'] | FoldersOf<C>['slug'] | PluginCollections<C>['slug'];
|
|
707
745
|
type GlobalSlug<C extends Config> = NonNullable<C['globals']>[number]['slug'];
|
|
708
746
|
/** Document type of a collection, e.g. `CollectionDocument<typeof config, 'posts'>`. */
|
|
709
747
|
type CollectionDocument<C extends Config, S extends CollectionSlug<C>> = S extends 'media' ? MediaDocument : InferCollection<CollectionBySlug<C, S>, C>;
|
|
@@ -998,6 +1036,24 @@ declare class MediaFolders {
|
|
|
998
1036
|
private rows;
|
|
999
1037
|
/** A role's level in a folder (`null`: the top level): the nearest permissions up the tree. */
|
|
1000
1038
|
private levelIn;
|
|
1039
|
+
/** Whether files in a folder (`null`: the top level) are private: it or a folder above is. */
|
|
1040
|
+
private privateIn;
|
|
1041
|
+
/** Whether files put in this folder are private. */
|
|
1042
|
+
isPrivate(id: ID | null): Promise<boolean>;
|
|
1043
|
+
/** The folder and every folder inside it. */
|
|
1044
|
+
private within;
|
|
1045
|
+
/** Folders whose privacy follows this one: it and those inside, with their privacy now. */
|
|
1046
|
+
subtree(id: ID): Promise<{
|
|
1047
|
+
id: ID;
|
|
1048
|
+
private: boolean;
|
|
1049
|
+
}[]>;
|
|
1050
|
+
/**
|
|
1051
|
+
* The folder with this key (`folder: 'banners'` on upload fields), made at the top level when
|
|
1052
|
+
* there is none yet.
|
|
1053
|
+
*/
|
|
1054
|
+
keyed(key: string): Promise<ID>;
|
|
1055
|
+
/** Whether a file in `folder` is in the folder with this key, or below it. */
|
|
1056
|
+
inKeyed(key: string, folder: ID | null): Promise<boolean>;
|
|
1001
1057
|
/** The user's level in a folder (`null`: the top level). */
|
|
1002
1058
|
level(user: AuthUser | null, id: ID | null): Promise<FolderLevel | 'none'>;
|
|
1003
1059
|
/**
|
|
@@ -1165,6 +1221,11 @@ declare class EasyCMS<C extends Config = Config> {
|
|
|
1165
1221
|
readonly auth: Auth;
|
|
1166
1222
|
/** Where uploaded files are stored. */
|
|
1167
1223
|
readonly storage: StorageAdapter;
|
|
1224
|
+
/**
|
|
1225
|
+
* Where files in private folders are stored (`upload.privateStorage`, else `storage` when it has
|
|
1226
|
+
* no public URLs); `null` when private folders can't be used.
|
|
1227
|
+
*/
|
|
1228
|
+
readonly privateStorage: StorageAdapter | null;
|
|
1168
1229
|
/** Project root: relative paths in the config (e.g. `admin.modules`) start here. */
|
|
1169
1230
|
readonly cwd: string;
|
|
1170
1231
|
/** Roles and their permissions (`auth.rbac`, Settings → Roles). */
|
|
@@ -1176,7 +1237,7 @@ declare class EasyCMS<C extends Config = Config> {
|
|
|
1176
1237
|
private readonly versions;
|
|
1177
1238
|
private readonly webhooks;
|
|
1178
1239
|
private readonly mailer;
|
|
1179
|
-
constructor(config: ResolvedConfig, db: Database, logger?: Logger, storage?: StorageAdapter, cwd?: string);
|
|
1240
|
+
constructor(config: ResolvedConfig, db: Database, logger?: Logger, storage?: StorageAdapter, cwd?: string, privateStorage?: StorageAdapter | null);
|
|
1180
1241
|
find<S extends Slug<C>>(collection: S, options?: FindOptions): Promise<PaginatedDocs<Doc<C, S>>>;
|
|
1181
1242
|
findById<S extends Slug<C>>(collection: S, id: ID, options?: ReadOptions): Promise<Doc<C, S> | null>;
|
|
1182
1243
|
count<S extends Slug<C>>(collection: S, options?: {
|
|
@@ -1214,6 +1275,43 @@ declare class EasyCMS<C extends Config = Config> {
|
|
|
1214
1275
|
}>;
|
|
1215
1276
|
/** Public URL of a stored file. */
|
|
1216
1277
|
mediaURL(key: string): string;
|
|
1278
|
+
/**
|
|
1279
|
+
* A link to a private file that works without signing in until it expires: `expiresIn` in
|
|
1280
|
+
* seconds or as `'30m'`, `'1h'` (the default), `'7d'` (the most). Public files get their usual
|
|
1281
|
+
* URL. `size`: one of the file's resized copies. Links stop working when `secret` changes or
|
|
1282
|
+
* the file stops being private.
|
|
1283
|
+
*/
|
|
1284
|
+
signedMediaURL(doc: {
|
|
1285
|
+
filename?: unknown;
|
|
1286
|
+
private?: unknown;
|
|
1287
|
+
sizes?: unknown;
|
|
1288
|
+
}, options?: {
|
|
1289
|
+
expiresIn?: number | string;
|
|
1290
|
+
size?: string;
|
|
1291
|
+
}): string;
|
|
1292
|
+
/** Whether a signed link to a private file is genuine and not expired. */
|
|
1293
|
+
verifyMediaSignature(key: string, expires: string | null, signature: string | null): boolean;
|
|
1294
|
+
private mediaSignature;
|
|
1295
|
+
/**
|
|
1296
|
+
* How many documents use any of these files in a top-level upload field (rich text and fields
|
|
1297
|
+
* inside groups, arrays and blocks are not counted). Drafts count too.
|
|
1298
|
+
*/
|
|
1299
|
+
mediaUsage(ids: readonly ID[]): Promise<number>;
|
|
1300
|
+
/** The storage for public files, or for private ones. */
|
|
1301
|
+
storageFor(isPrivate: boolean): StorageAdapter;
|
|
1302
|
+
/**
|
|
1303
|
+
* Copies a file and its resized copies to the other storage under their new names (`.private`
|
|
1304
|
+
* added or removed). Returns the fields to save, and `cleanUp` to delete the old copies once
|
|
1305
|
+
* the document points to the new ones.
|
|
1306
|
+
*/
|
|
1307
|
+
private relocate;
|
|
1308
|
+
/** A folder's name, parent and permissions; private only with somewhere private to keep files. */
|
|
1309
|
+
private validateFolder;
|
|
1310
|
+
/** After a folder became private or public (or moved): its files follow, in every subfolder. */
|
|
1311
|
+
private settlePrivacy;
|
|
1312
|
+
/** Files at the top level are public. */
|
|
1313
|
+
private settleTopLevel;
|
|
1314
|
+
private settleFiles;
|
|
1217
1315
|
update<S extends Slug<C>>(collection: S, id: ID, data: Update<C, S>, options?: UpdateOptions): Promise<Doc<C, S>>;
|
|
1218
1316
|
/**
|
|
1219
1317
|
* Takes a document off the site: its status becomes `draft`. With versions and drafts, a plain
|
|
@@ -1600,6 +1698,14 @@ interface UploadField extends BaseField<'upload', never> {
|
|
|
1600
1698
|
* offers only these, and saving refuses others.
|
|
1601
1699
|
*/
|
|
1602
1700
|
readonly mimeTypes?: readonly string[];
|
|
1701
|
+
/**
|
|
1702
|
+
* With `upload.folders`: the `key` of the media folder this field's files go in, e.g.
|
|
1703
|
+
* `'banners'`. Its picker opens there and its uploads land there; the folder is made when it
|
|
1704
|
+
* is first needed.
|
|
1705
|
+
*/
|
|
1706
|
+
readonly folder?: string;
|
|
1707
|
+
/** With `folder`: only files in that folder (and its subfolders) may be chosen. */
|
|
1708
|
+
readonly folderOnly?: boolean;
|
|
1603
1709
|
}
|
|
1604
1710
|
interface FilterOptionsArgs {
|
|
1605
1711
|
/** The document being edited; `undefined` while it is being created. */
|
|
@@ -2248,6 +2354,13 @@ interface UploadConfig {
|
|
|
2248
2354
|
* `auth.rbac`, admins can also choose which roles see and change each folder. Off by default.
|
|
2249
2355
|
*/
|
|
2250
2356
|
readonly folders?: boolean;
|
|
2357
|
+
/**
|
|
2358
|
+
* Where files in private folders are kept: never served publicly, only through the API to
|
|
2359
|
+
* users who may see them or with `cms.signedMediaURL()`. Default: `storage`, when it has no
|
|
2360
|
+
* public URL (the local disk, Netlify Blobs); a storage with public URLs (S3 with `publicUrl`,
|
|
2361
|
+
* a public Vercel Blob store) needs one, e.g. `vercelBlobStorage({ access: 'private' })`.
|
|
2362
|
+
*/
|
|
2363
|
+
readonly privateStorage?: StorageAdapter;
|
|
2251
2364
|
}
|
|
2252
2365
|
interface RoutesConfig {
|
|
2253
2366
|
/** Where the REST API is served. Default `/api/cms`. */
|
|
@@ -2507,7 +2620,7 @@ interface ResolvedConfig extends Omit<Config, 'cors' | 'localization' | 'routes'
|
|
|
2507
2620
|
readonly localization: Required<LocalizationConfig> | null;
|
|
2508
2621
|
readonly routes: Required<RoutesConfig>;
|
|
2509
2622
|
readonly admin: Required<AdminConfig>;
|
|
2510
|
-
readonly upload: Required<Omit<UploadConfig, 'storage' | 'fromURL'>> & Pick<UploadConfig, 'storage' | 'fromURL'>;
|
|
2623
|
+
readonly upload: Required<Omit<UploadConfig, 'storage' | 'fromURL' | 'privateStorage'>> & Pick<UploadConfig, 'storage' | 'fromURL' | 'privateStorage'>;
|
|
2511
2624
|
readonly auth: Required<AuthConfig>;
|
|
2512
2625
|
/** `false` without an audit log. */
|
|
2513
2626
|
readonly audit: Required<AuditConfig> | false;
|
|
@@ -2536,6 +2649,11 @@ type ApiKeyGlobalOperation = (typeof API_KEY_GLOBAL_OPERATIONS)[number];
|
|
|
2536
2649
|
interface ApiKeyPermissions {
|
|
2537
2650
|
readonly collections?: Readonly<Record<string, readonly ApiKeyOperation[]>>;
|
|
2538
2651
|
readonly globals?: Readonly<Record<string, readonly ApiKeyGlobalOperation[]>>;
|
|
2652
|
+
/**
|
|
2653
|
+
* With `upload.folders`: the media folders (ids) the key may use, with their subfolders. Empty
|
|
2654
|
+
* or missing: every folder.
|
|
2655
|
+
*/
|
|
2656
|
+
readonly folders?: readonly (string | number)[];
|
|
2539
2657
|
}
|
|
2540
2658
|
/** Set on `AuthUser.apiKey` when a request authenticated with an API key. */
|
|
2541
2659
|
interface ApiKeyContext {
|
|
@@ -2662,6 +2780,8 @@ interface Reference {
|
|
|
2662
2780
|
readonly filterOptions?: FilterOptions;
|
|
2663
2781
|
/** The upload's `mimeTypes`, checked when saving. */
|
|
2664
2782
|
readonly mimeTypes?: readonly string[];
|
|
2783
|
+
/** The upload's `folder` with `folderOnly`: the file must be in it, checked when saving. */
|
|
2784
|
+
readonly folderOnly?: string;
|
|
2665
2785
|
}
|
|
2666
2786
|
interface ValidateOptions {
|
|
2667
2787
|
readonly operation: 'create' | 'update';
|
|
@@ -2845,6 +2965,11 @@ interface AdminField {
|
|
|
2845
2965
|
to?: string;
|
|
2846
2966
|
/** An upload's allowed file types, e.g. `image/*`. */
|
|
2847
2967
|
mimeTypes?: string[];
|
|
2968
|
+
/** An upload's media folder (`folder`): where its picker opens; `only`: nowhere else. */
|
|
2969
|
+
folder?: {
|
|
2970
|
+
id: ID;
|
|
2971
|
+
only: boolean;
|
|
2972
|
+
};
|
|
2848
2973
|
/** A relationship with `filterOptions`: the picker asks the server which documents fit. */
|
|
2849
2974
|
filtered?: boolean;
|
|
2850
2975
|
from?: string;
|
|
@@ -2971,6 +3096,7 @@ interface AdminSchema {
|
|
|
2971
3096
|
*/
|
|
2972
3097
|
folders?: {
|
|
2973
3098
|
permissions: boolean;
|
|
3099
|
+
private: boolean;
|
|
2974
3100
|
};
|
|
2975
3101
|
/** Which saved deliveries the admin can show (webhooks, emails), for users who may see them. */
|
|
2976
3102
|
deliveries?: {
|
|
@@ -3105,4 +3231,4 @@ declare function validateConfig(config: Config): ConfigIssue[];
|
|
|
3105
3231
|
/** The version of `@easy-cms/core`, set when the package is built. */
|
|
3106
3232
|
declare const VERSION: string;
|
|
3107
3233
|
|
|
3108
|
-
export { ADMIN_ICONS, API_KEYS, API_KEY_GLOBAL_OPERATIONS, API_KEY_OPERATIONS, type Access, type AccessArgs, type AccessOptions, type AdminAttention, type AdminBackup, type AdminBackups, type AdminBrand, type AdminCollection, type AdminComponent, type AdminComponentRef, type AdminConfig, type AdminDeliveries, type AdminDelivery, type AdminEmail, type AdminEmailDelivery, type AdminField, type AdminGlobal, type AdminIcon, type AdminLocale, type AdminPage, type AdminPageRef, type AdminRole, type AdminRoleChange, type AdminRoleField, type AdminRoleTarget, type AdminRoleView, type AdminRoles, type AdminSchema, type AdminSso, type AdminStatus, type AdminViewAccess, type AdminWebhookDelivery, type AdminWidgetRef, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ApiKeyContext, type ApiKeyGlobalOperation, type ApiKeyOperation, type ApiKeyPermissions, type ArrayField, type AuditChange, type AuditConfig, type AuditEntry, AuditLog, type AuditPage, type AuditVerification, Auth, type AuthConfig, type AuthProvider, type AuthProviderProfile, type AuthProviderRequest, type AuthUser, BUILTIN_COLLECTIONS, type BackupState, type BackupsConfig, 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 DeliveryKind, type DeliveryState, type DepthOptions, type DocumentOf, EXTENSIONS, EasyCMS, EasyCMSError, type EmailAdapter, type EmailCheck, type EmailField, type EmailMessage, type EmailSetting, type Endpoint, type EndpointRequest, FIELD_TYPES, FIELD_TYPE_BASES, FOLDER_LEVELS, type Field, type FieldAccess, type FieldAccessFn, type FieldAdmin, type FieldError, type FieldRule, 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, type FolderLevel, type FolderPermissions, 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, MEDIA_FOLDERS, MIME_GROUPS, MIN_PASSWORD_LENGTH, MIN_SECRET_LENGTH, type MediaDocument, MediaFolders, type MediaSize, type MigrationInfo, NotFoundError, type NumberField, type Operation, type PaginatedDocs, type PasswordEmail, type PasswordEmailArgs, type PasswordEmailFn, type PasswordLinkOptions, PayloadTooLargeError, type Plugin, type PluginInfo, type PluginTypes, type PreviewTarget, type PreviewURL, QueryError, ROLE_OPERATIONS, type RawDocument, type ReadOptions, type RelationshipField, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoleOperation, type RolePermissions, Roles, type RoutesConfig, SESSION_COOKIE, SSO_COOKIE, type ScheduledJob, SchemaError, type SchemaMode, type SelectField, type SelectOption, type Session, type SlugField, type SlugOf, type SsoOutcome, type SsoProviderRef, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, type TypedPlugin, UnauthorizedError, type UpdateInput, type UpdateOptions, type UploadConfig, type UploadField, type UploadFromURLConfig, type UserIdentity, VERSION, ValidationError, type Version, type VersionSummary, type VersionsConfig, WEBHOOK_EVENTS, type WebhookConfig, type WebhookEvent, type WebhookPayload, type Where, type WhereOperators, anyone, applyDefaults, applyPlugins, configSignature, consoleEmail, consoleLogger, copyDatabase, createEasyCMS, createRestHandler, createRootEndpointHandler, defineConfig, defineFieldType, definePlugin, expandMimeTypes, 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, writeBackupFile };
|
|
3234
|
+
export { ADMIN_ICONS, API_KEYS, API_KEY_GLOBAL_OPERATIONS, API_KEY_OPERATIONS, type Access, type AccessArgs, type AccessOptions, type AdminAttention, type AdminBackup, type AdminBackups, type AdminBrand, type AdminCollection, type AdminComponent, type AdminComponentRef, type AdminConfig, type AdminDeliveries, type AdminDelivery, type AdminEmail, type AdminEmailDelivery, type AdminField, type AdminGlobal, type AdminIcon, type AdminLocale, type AdminPage, type AdminPageRef, type AdminRole, type AdminRoleChange, type AdminRoleField, type AdminRoleTarget, type AdminRoleView, type AdminRoles, type AdminSchema, type AdminSso, type AdminStatus, type AdminViewAccess, type AdminWebhookDelivery, type AdminWidgetRef, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ApiKeyContext, type ApiKeyGlobalOperation, type ApiKeyOperation, type ApiKeyPermissions, type ArrayField, type AuditChange, type AuditConfig, type AuditEntry, AuditLog, type AuditPage, type AuditVerification, Auth, type AuthConfig, type AuthProvider, type AuthProviderProfile, type AuthProviderRequest, type AuthUser, BUILTIN_COLLECTIONS, type BackupState, type BackupsConfig, type BeforeChangeHook, type BeforeDeleteHook, type BeforeValidateHook, type Block, type BlocksField, type BooleanField, type BuiltinApiKeysCollection, type BuiltinMediaCollection, type BuiltinMediaFoldersCollection, 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 DeliveryKind, type DeliveryState, type DepthOptions, type DocumentOf, EXTENSIONS, EasyCMS, EasyCMSError, type EmailAdapter, type EmailCheck, type EmailField, type EmailMessage, type EmailSetting, type Endpoint, type EndpointRequest, FIELD_TYPES, FIELD_TYPE_BASES, FOLDER_LEVELS, type Field, type FieldAccess, type FieldAccessFn, type FieldAdmin, type FieldError, type FieldRule, 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, type FolderLevel, type FolderPermissions, 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, MEDIA_FOLDERS, MIME_GROUPS, MIN_PASSWORD_LENGTH, MIN_SECRET_LENGTH, type MediaDocument, MediaFolders, type MediaSize, type MigrationInfo, NotFoundError, type NumberField, type Operation, type PaginatedDocs, type PasswordEmail, type PasswordEmailArgs, type PasswordEmailFn, type PasswordLinkOptions, PayloadTooLargeError, type Plugin, type PluginInfo, type PluginTypes, type PreviewTarget, type PreviewURL, QueryError, ROLE_OPERATIONS, type RawDocument, type ReadOptions, type RelationshipField, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoleOperation, type RolePermissions, Roles, type RoutesConfig, SESSION_COOKIE, SSO_COOKIE, type ScheduledJob, SchemaError, type SchemaMode, type SelectField, type SelectOption, type Session, type SlugField, type SlugOf, type SsoOutcome, type SsoProviderRef, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, type TypedPlugin, UnauthorizedError, type UpdateInput, type UpdateOptions, type UploadConfig, type UploadField, type UploadFromURLConfig, type UserIdentity, VERSION, ValidationError, type Version, type VersionSummary, type VersionsConfig, WEBHOOK_EVENTS, type WebhookConfig, type WebhookEvent, type WebhookPayload, type Where, type WhereOperators, anyone, applyDefaults, applyPlugins, configSignature, consoleEmail, consoleLogger, copyDatabase, createEasyCMS, createRestHandler, createRootEndpointHandler, defineConfig, defineFieldType, definePlugin, expandMimeTypes, 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, writeBackupFile };
|
package/dist/index.js
CHANGED
|
@@ -123,9 +123,13 @@ function cleanPermissions(value, known) {
|
|
|
123
123
|
}
|
|
124
124
|
return out;
|
|
125
125
|
};
|
|
126
|
+
const folders = Array.isArray(input.folders) ? input.folders.filter(
|
|
127
|
+
(f) => typeof f === "string" || typeof f === "number"
|
|
128
|
+
) : [];
|
|
126
129
|
return {
|
|
127
130
|
collections: pick(input.collections, known.collections, API_KEY_OPERATIONS),
|
|
128
|
-
globals: pick(input.globals, known.globals, API_KEY_GLOBAL_OPERATIONS)
|
|
131
|
+
globals: pick(input.globals, known.globals, API_KEY_GLOBAL_OPERATIONS),
|
|
132
|
+
...folders.length > 0 ? { folders } : {}
|
|
129
133
|
};
|
|
130
134
|
}
|
|
131
135
|
|
|
@@ -286,6 +290,14 @@ function mediaFields() {
|
|
|
286
290
|
{ name: "alt", type: "text", label: { en: "Alternative text", th: "\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21\u0E41\u0E17\u0E19\u0E23\u0E39\u0E1B" } }
|
|
287
291
|
];
|
|
288
292
|
}
|
|
293
|
+
var privateField = {
|
|
294
|
+
name: "private",
|
|
295
|
+
type: "boolean",
|
|
296
|
+
index: true,
|
|
297
|
+
access: systemField,
|
|
298
|
+
position: "sidebar",
|
|
299
|
+
label: { en: "Private", th: "\u0E2A\u0E48\u0E27\u0E19\u0E15\u0E31\u0E27" }
|
|
300
|
+
};
|
|
289
301
|
var adminField = {
|
|
290
302
|
read: ({ user }) => user?.role === "admin" && !user.apiKey,
|
|
291
303
|
update: ({ user }) => user?.role === "admin" && !user.apiKey
|
|
@@ -324,6 +336,22 @@ var mediaFoldersCollection = {
|
|
|
324
336
|
index: true,
|
|
325
337
|
label: { en: "In folder", th: "\u0E2D\u0E22\u0E39\u0E48\u0E43\u0E19\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C" }
|
|
326
338
|
},
|
|
339
|
+
// For code to find the folder (`folder: 'banners'` on upload fields); set when it is made.
|
|
340
|
+
{
|
|
341
|
+
name: "key",
|
|
342
|
+
type: "text",
|
|
343
|
+
unique: true,
|
|
344
|
+
maxLength: 60,
|
|
345
|
+
access: { update: ({ id }) => id === void 0 },
|
|
346
|
+
label: { en: "Key", th: "\u0E04\u0E35\u0E22\u0E4C" }
|
|
347
|
+
},
|
|
348
|
+
// Its files and subfolders are private: only for users who may see them, and signed links.
|
|
349
|
+
{
|
|
350
|
+
name: "private",
|
|
351
|
+
type: "boolean",
|
|
352
|
+
access: { update: adminField.update },
|
|
353
|
+
label: { en: "Private", th: "\u0E2A\u0E48\u0E27\u0E19\u0E15\u0E31\u0E27" }
|
|
354
|
+
},
|
|
327
355
|
// `null`: as the parent folder. Otherwise each role's level (`view`, `edit`, `manage`);
|
|
328
356
|
// roles not listed get nothing.
|
|
329
357
|
{
|
|
@@ -351,7 +379,11 @@ function withMedia(config) {
|
|
|
351
379
|
icon: "image",
|
|
352
380
|
useAsTitle: "filename",
|
|
353
381
|
...custom,
|
|
354
|
-
fields: [
|
|
382
|
+
fields: [
|
|
383
|
+
...mediaFields(),
|
|
384
|
+
...folders ? [folderField, privateField] : [],
|
|
385
|
+
...custom?.fields ?? []
|
|
386
|
+
],
|
|
355
387
|
access: {
|
|
356
388
|
read: anyone,
|
|
357
389
|
create: isLoggedIn,
|
|
@@ -380,8 +412,9 @@ function withMedia(config) {
|
|
|
380
412
|
afterDelete: [
|
|
381
413
|
async ({ doc, cms }) => {
|
|
382
414
|
const sizes = Object.values(doc.sizes ?? {});
|
|
415
|
+
const storage = cms.storageFor(doc.private === true);
|
|
383
416
|
for (const key2 of [doc.filename, ...sizes.map((s) => s.filename)]) {
|
|
384
|
-
if (typeof key2 === "string") await
|
|
417
|
+
if (typeof key2 === "string") await storage.delete(key2);
|
|
385
418
|
}
|
|
386
419
|
},
|
|
387
420
|
...custom?.hooks?.afterDelete ?? []
|
|
@@ -1067,6 +1100,17 @@ function storageKey(originalName, type, random2) {
|
|
|
1067
1100
|
const base = name.replace(/(?<=.)\.[^.]*$/, "").normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{M}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "file";
|
|
1068
1101
|
return `${base}-${random2}.${EXTENSIONS[type] ?? "bin"}`;
|
|
1069
1102
|
}
|
|
1103
|
+
function isPrivateKey(key2) {
|
|
1104
|
+
return /\.private[.-]/.test(key2);
|
|
1105
|
+
}
|
|
1106
|
+
function withPrivacy(key2, isPrivate) {
|
|
1107
|
+
const plain = key2.replace(/\.private(?=\.[^.]+$)/, "");
|
|
1108
|
+
return isPrivate ? plain.replace(/\.([^.]+)$/, ".private.$1") : plain;
|
|
1109
|
+
}
|
|
1110
|
+
function sizeKey(filename, size) {
|
|
1111
|
+
return filename.replace(/\.([^.]+)$/, `-${size}.$1`);
|
|
1112
|
+
}
|
|
1113
|
+
var PRIVATE_KEY = /^[\p{L}\p{M}\p{N}-]+\.private(-[\p{L}\p{N}_-]+)?\.[a-z0-9]+$/u;
|
|
1070
1114
|
|
|
1071
1115
|
// src/fields.ts
|
|
1072
1116
|
var isHasMany = (field) => (field.type === "select" || field.type === "relationship" || field.type === "upload") && field.hasMany === true;
|
|
@@ -2806,7 +2850,8 @@ async function normalizeValue(field, raw, path, options, fail, errors, reference
|
|
|
2806
2850
|
collection: target,
|
|
2807
2851
|
id,
|
|
2808
2852
|
...field.type === "relationship" && field.filterOptions ? { filterOptions: field.filterOptions } : {},
|
|
2809
|
-
...field.type === "upload" && field.mimeTypes ? { mimeTypes: field.mimeTypes } : {}
|
|
2853
|
+
...field.type === "upload" && field.mimeTypes ? { mimeTypes: field.mimeTypes } : {},
|
|
2854
|
+
...field.type === "upload" && field.folder && field.folderOnly ? { folderOnly: field.folder } : {}
|
|
2810
2855
|
});
|
|
2811
2856
|
}
|
|
2812
2857
|
return many ? ids : ids[0];
|
|
@@ -4503,6 +4548,25 @@ function validateCors(cors, add) {
|
|
|
4503
4548
|
}
|
|
4504
4549
|
function validateUpload(config, add) {
|
|
4505
4550
|
const upload = config.upload;
|
|
4551
|
+
if (upload?.folders !== true) {
|
|
4552
|
+
const walk = (fields, path) => {
|
|
4553
|
+
for (const [i, field] of fields.entries()) {
|
|
4554
|
+
const at = `${path}.fields[${i}]`;
|
|
4555
|
+
if (field.type === "upload" && field.folder !== void 0)
|
|
4556
|
+
add(`${at}.folder`, "needs upload.folders: true", "upload: { folders: true }");
|
|
4557
|
+
if ((field.type === "group" || field.type === "array") && Array.isArray(field.fields))
|
|
4558
|
+
walk(field.fields, at);
|
|
4559
|
+
if (field.type === "blocks" && Array.isArray(field.blocks)) {
|
|
4560
|
+
for (const [b, block] of field.blocks.entries())
|
|
4561
|
+
if (Array.isArray(block?.fields)) walk(block.fields, `${at}.blocks[${b}]`);
|
|
4562
|
+
}
|
|
4563
|
+
}
|
|
4564
|
+
};
|
|
4565
|
+
for (const [i, c] of (config.collections ?? []).entries())
|
|
4566
|
+
if (Array.isArray(c?.fields)) walk(c.fields, `collections[${i}]`);
|
|
4567
|
+
for (const [i, g] of (config.globals ?? []).entries())
|
|
4568
|
+
if (Array.isArray(g?.fields)) walk(g.fields, `globals[${i}]`);
|
|
4569
|
+
}
|
|
4506
4570
|
if (upload === void 0) return;
|
|
4507
4571
|
if (upload.maxFileSize !== void 0 && !(Number.isInteger(upload.maxFileSize) && upload.maxFileSize > 0)) {
|
|
4508
4572
|
add("upload.maxFileSize", "must be a positive integer (bytes)");
|
|
@@ -4519,6 +4583,9 @@ function validateUpload(config, add) {
|
|
|
4519
4583
|
);
|
|
4520
4584
|
if (upload.folders !== void 0 && typeof upload.folders !== "boolean")
|
|
4521
4585
|
add("upload.folders", "must be true or false");
|
|
4586
|
+
const privateStorage = upload.privateStorage;
|
|
4587
|
+
if (privateStorage !== void 0 && (typeof privateStorage !== "object" || privateStorage === null || typeof privateStorage.get !== "function"))
|
|
4588
|
+
add("upload.privateStorage", "must be a storage adapter", "e.g. s3Storage({ \u2026 })");
|
|
4522
4589
|
const fromURL = upload.fromURL;
|
|
4523
4590
|
if (fromURL === void 0) return;
|
|
4524
4591
|
if (typeof fromURL !== "object" || fromURL === null) {
|
|
@@ -4544,6 +4611,7 @@ function validateUpload(config, add) {
|
|
|
4544
4611
|
if (allowPrivate !== void 0 && typeof allowPrivate !== "boolean")
|
|
4545
4612
|
add("upload.fromURL.allowPrivate", "must be true or false");
|
|
4546
4613
|
}
|
|
4614
|
+
var FOLDER_KEY = /^[a-z0-9][a-z0-9_-]{0,59}$/;
|
|
4547
4615
|
var MIME_PATTERN = /^([\w.+-]+\/(\*|[\w.+-]+)|documents|office|archives)$/i;
|
|
4548
4616
|
var HOST_PATTERN = /^(\*|(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*)$/i;
|
|
4549
4617
|
function validateAudit(config, add) {
|
|
@@ -4792,6 +4860,14 @@ function validateField(field, path, siblings, collectionSlugs, add) {
|
|
|
4792
4860
|
break;
|
|
4793
4861
|
case "upload":
|
|
4794
4862
|
validateManyRange(field, path, add);
|
|
4863
|
+
if (field.folder !== void 0 && !FOLDER_KEY.test(String(field.folder)))
|
|
4864
|
+
add(
|
|
4865
|
+
`${path}.folder`,
|
|
4866
|
+
'must be a folder key: lowercase letters, digits, "-" or "_"',
|
|
4867
|
+
"e.g. folder: 'banners'"
|
|
4868
|
+
);
|
|
4869
|
+
if (field.folderOnly !== void 0 && field.folder === void 0)
|
|
4870
|
+
add(`${path}.folderOnly`, "needs folder");
|
|
4795
4871
|
if (field.mimeTypes !== void 0) {
|
|
4796
4872
|
const types = field.mimeTypes;
|
|
4797
4873
|
if (!Array.isArray(types) || types.length === 0 || !types.every((t) => typeof t === "string" && MIME_PATTERN.test(t))) {
|
|
@@ -5070,7 +5146,8 @@ async function resolveConfig(input) {
|
|
|
5070
5146
|
imageSizes: config.upload?.imageSizes ?? [],
|
|
5071
5147
|
folders: config.upload?.folders === true,
|
|
5072
5148
|
...config.upload?.storage ? { storage: config.upload.storage } : {},
|
|
5073
|
-
...config.upload?.fromURL ? { fromURL: config.upload.fromURL } : {}
|
|
5149
|
+
...config.upload?.fromURL ? { fromURL: config.upload.fromURL } : {},
|
|
5150
|
+
...config.upload?.privateStorage ? { privateStorage: config.upload.privateStorage } : {}
|
|
5074
5151
|
},
|
|
5075
5152
|
auth: {
|
|
5076
5153
|
roles: config.auth?.roles ?? DEFAULT_ROLES,
|
|
@@ -5173,7 +5250,7 @@ async function loadConfig(options = {}) {
|
|
|
5173
5250
|
}
|
|
5174
5251
|
|
|
5175
5252
|
// src/local-api.ts
|
|
5176
|
-
import { randomBytes as randomBytes5 } from "crypto";
|
|
5253
|
+
import { createHmac as createHmac5, randomBytes as randomBytes5, timingSafeEqual as timingSafeEqual4 } from "crypto";
|
|
5177
5254
|
|
|
5178
5255
|
// src/localization.ts
|
|
5179
5256
|
var ALL_LOCALES = "all";
|
|
@@ -5286,6 +5363,14 @@ var silentLogger = { info() {
|
|
|
5286
5363
|
var FOLDER_LEVELS = ["view", "edit", "manage"];
|
|
5287
5364
|
var RANK = { none: 0, view: 1, edit: 2, manage: 3 };
|
|
5288
5365
|
var TTL2 = 1e4;
|
|
5366
|
+
var PUBLIC_FILES = {
|
|
5367
|
+
or: [{ private: { equals: false } }, { private: { exists: false } }]
|
|
5368
|
+
};
|
|
5369
|
+
function keyFolders(user) {
|
|
5370
|
+
const key2 = user?.apiKey;
|
|
5371
|
+
const folders = key2?.permissions?.folders;
|
|
5372
|
+
return folders && folders.length > 0 ? folders : void 0;
|
|
5373
|
+
}
|
|
5289
5374
|
function needed(collection2, operation) {
|
|
5290
5375
|
if (operation === "read") return "view";
|
|
5291
5376
|
return collection2 === MEDIA ? "edit" : "manage";
|
|
@@ -5318,6 +5403,8 @@ var MediaFolders = class {
|
|
|
5318
5403
|
id: doc.id,
|
|
5319
5404
|
parent: doc.parent ?? null,
|
|
5320
5405
|
name: String(doc.name ?? ""),
|
|
5406
|
+
key: typeof doc.key === "string" && doc.key ? doc.key : null,
|
|
5407
|
+
private: doc.private === true,
|
|
5321
5408
|
permissions: permissions && typeof permissions === "object" && !Array.isArray(permissions) ? permissions : null
|
|
5322
5409
|
});
|
|
5323
5410
|
}
|
|
@@ -5338,6 +5425,83 @@ var MediaFolders = class {
|
|
|
5338
5425
|
}
|
|
5339
5426
|
return "manage";
|
|
5340
5427
|
}
|
|
5428
|
+
/** Whether files in a folder (`null`: the top level) are private: it or a folder above is. */
|
|
5429
|
+
privateIn(rows, id) {
|
|
5430
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5431
|
+
let current = id === null ? void 0 : rows.get(String(id));
|
|
5432
|
+
while (current && !seen.has(String(current.id))) {
|
|
5433
|
+
if (current.private) return true;
|
|
5434
|
+
seen.add(String(current.id));
|
|
5435
|
+
current = current.parent === null ? void 0 : rows.get(String(current.parent));
|
|
5436
|
+
}
|
|
5437
|
+
return false;
|
|
5438
|
+
}
|
|
5439
|
+
/** Whether files put in this folder are private. */
|
|
5440
|
+
async isPrivate(id) {
|
|
5441
|
+
if (!this.enabled || id === null) return false;
|
|
5442
|
+
return this.privateIn(await this.rows(), id);
|
|
5443
|
+
}
|
|
5444
|
+
/** The folder and every folder inside it. */
|
|
5445
|
+
within(rows, ids) {
|
|
5446
|
+
const out = new Set(ids.map(String));
|
|
5447
|
+
let grew = true;
|
|
5448
|
+
while (grew) {
|
|
5449
|
+
grew = false;
|
|
5450
|
+
for (const row of rows.values())
|
|
5451
|
+
if (row.parent !== null && out.has(String(row.parent)) && !out.has(String(row.id))) {
|
|
5452
|
+
out.add(String(row.id));
|
|
5453
|
+
grew = true;
|
|
5454
|
+
}
|
|
5455
|
+
}
|
|
5456
|
+
return [...rows.values()].filter((r) => out.has(String(r.id))).map((r) => r.id);
|
|
5457
|
+
}
|
|
5458
|
+
/** Folders whose privacy follows this one: it and those inside, with their privacy now. */
|
|
5459
|
+
async subtree(id) {
|
|
5460
|
+
const rows = await this.fresh();
|
|
5461
|
+
return this.within(rows, [id]).map((f) => ({ id: f, private: this.privateIn(rows, f) }));
|
|
5462
|
+
}
|
|
5463
|
+
/**
|
|
5464
|
+
* The folder with this key (`folder: 'banners'` on upload fields), made at the top level when
|
|
5465
|
+
* there is none yet.
|
|
5466
|
+
*/
|
|
5467
|
+
async keyed(key2) {
|
|
5468
|
+
const find = async () => [...(await this.rows()).values()].find((r) => r.key === key2);
|
|
5469
|
+
let found = await find();
|
|
5470
|
+
if (!found) {
|
|
5471
|
+
this.invalidate();
|
|
5472
|
+
found = await find();
|
|
5473
|
+
}
|
|
5474
|
+
if (found) return found.id;
|
|
5475
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
5476
|
+
try {
|
|
5477
|
+
const doc = await this.db.create({
|
|
5478
|
+
collection: MEDIA_FOLDERS,
|
|
5479
|
+
data: {
|
|
5480
|
+
name: key2,
|
|
5481
|
+
key: key2,
|
|
5482
|
+
parent: null,
|
|
5483
|
+
permissions: null,
|
|
5484
|
+
private: false,
|
|
5485
|
+
createdAt: now,
|
|
5486
|
+
updatedAt: now
|
|
5487
|
+
}
|
|
5488
|
+
});
|
|
5489
|
+
this.invalidate();
|
|
5490
|
+
return doc.id;
|
|
5491
|
+
} catch (error) {
|
|
5492
|
+
this.invalidate();
|
|
5493
|
+
const again = await find();
|
|
5494
|
+
if (again) return again.id;
|
|
5495
|
+
throw error;
|
|
5496
|
+
}
|
|
5497
|
+
}
|
|
5498
|
+
/** Whether a file in `folder` is in the folder with this key, or below it. */
|
|
5499
|
+
async inKeyed(key2, folder) {
|
|
5500
|
+
if (folder === null) return false;
|
|
5501
|
+
const rows = await this.rows();
|
|
5502
|
+
const root = [...rows.values()].find((r) => r.key === key2);
|
|
5503
|
+
return !!root && this.within(rows, [root.id]).some((id) => String(id) === String(folder));
|
|
5504
|
+
}
|
|
5341
5505
|
/** The user's level in a folder (`null`: the top level). */
|
|
5342
5506
|
async level(user, id) {
|
|
5343
5507
|
if (!this.applies(user)) return "manage";
|
|
@@ -5348,7 +5512,15 @@ var MediaFolders = class {
|
|
|
5348
5512
|
* they add none (no folder limits this user).
|
|
5349
5513
|
*/
|
|
5350
5514
|
async where(user, collection2, operation) {
|
|
5351
|
-
if (collection2 !== MEDIA && collection2 !== MEDIA_FOLDERS
|
|
5515
|
+
if (!this.enabled || collection2 !== MEDIA && collection2 !== MEDIA_FOLDERS) return true;
|
|
5516
|
+
if (!user) return collection2 === MEDIA ? PUBLIC_FILES : true;
|
|
5517
|
+
const limited = keyFolders(user);
|
|
5518
|
+
if (limited) {
|
|
5519
|
+
const ids = this.within(await this.rows(), limited);
|
|
5520
|
+
if (collection2 === MEDIA_FOLDERS) return { id: { in: ids } };
|
|
5521
|
+
return operation === "create" ? true : { folder: { in: ids } };
|
|
5522
|
+
}
|
|
5523
|
+
if (!this.applies(user)) return true;
|
|
5352
5524
|
if (operation === "create") return true;
|
|
5353
5525
|
const need = RANK[needed(collection2, operation)];
|
|
5354
5526
|
const rows = await this.rows();
|
|
@@ -5365,6 +5537,13 @@ var MediaFolders = class {
|
|
|
5365
5537
|
}
|
|
5366
5538
|
/** Refuses putting a file (`media`) or a subfolder in a folder the user may not. */
|
|
5367
5539
|
async checkTarget(user, collection2, folder) {
|
|
5540
|
+
const limited = keyFolders(user);
|
|
5541
|
+
if (limited) {
|
|
5542
|
+
const ids = this.within(await this.rows(), limited).map(String);
|
|
5543
|
+
if (folder === null || !ids.includes(String(folder)))
|
|
5544
|
+
throw new ForbiddenError("This API key may not add to this folder");
|
|
5545
|
+
return;
|
|
5546
|
+
}
|
|
5368
5547
|
if (!this.applies(user)) return;
|
|
5369
5548
|
const need = collection2 === MEDIA ? "edit" : "manage";
|
|
5370
5549
|
if (RANK[await this.level(user, folder)] < RANK[need])
|
|
@@ -5729,6 +5908,7 @@ async function createEasyCMS(config, options = {}) {
|
|
|
5729
5908
|
const cwd = options.cwd ?? process.cwd();
|
|
5730
5909
|
const storage = resolved3.upload.storage ?? localStorage({ dir: resolved3.upload.dir });
|
|
5731
5910
|
await storage.init?.({ cwd });
|
|
5911
|
+
if (resolved3.upload.privateStorage) await resolved3.upload.privateStorage.init?.({ cwd });
|
|
5732
5912
|
const db = await resolved3.db.init({
|
|
5733
5913
|
config: resolved3,
|
|
5734
5914
|
cwd,
|
|
@@ -5757,6 +5937,11 @@ var EasyCMS = class {
|
|
|
5757
5937
|
auth;
|
|
5758
5938
|
/** Where uploaded files are stored. */
|
|
5759
5939
|
storage;
|
|
5940
|
+
/**
|
|
5941
|
+
* Where files in private folders are stored (`upload.privateStorage`, else `storage` when it has
|
|
5942
|
+
* no public URLs); `null` when private folders can't be used.
|
|
5943
|
+
*/
|
|
5944
|
+
privateStorage;
|
|
5760
5945
|
/** Project root: relative paths in the config (e.g. `admin.modules`) start here. */
|
|
5761
5946
|
cwd;
|
|
5762
5947
|
/** Roles and their permissions (`auth.rbac`, Settings → Roles). */
|
|
@@ -5768,11 +5953,12 @@ var EasyCMS = class {
|
|
|
5768
5953
|
versions;
|
|
5769
5954
|
webhooks;
|
|
5770
5955
|
mailer;
|
|
5771
|
-
constructor(config, db, logger = consoleLogger, storage = localStorage({ dir: config.upload.dir }), cwd = process.cwd()) {
|
|
5956
|
+
constructor(config, db, logger = consoleLogger, storage = localStorage({ dir: config.upload.dir }), cwd = process.cwd(), privateStorage) {
|
|
5772
5957
|
this.config = config;
|
|
5773
5958
|
this.db = db;
|
|
5774
5959
|
this.logger = logger;
|
|
5775
5960
|
this.storage = storage;
|
|
5961
|
+
this.privateStorage = privateStorage !== void 0 ? privateStorage : config.upload.privateStorage ?? (typeof storage.url === "function" ? null : storage);
|
|
5776
5962
|
this.cwd = cwd;
|
|
5777
5963
|
this.versions = new VersionStore(db);
|
|
5778
5964
|
this.roles = new Roles(
|
|
@@ -5895,17 +6081,27 @@ var EasyCMS = class {
|
|
|
5895
6081
|
]);
|
|
5896
6082
|
}
|
|
5897
6083
|
const random2 = randomBytes5(4).toString("hex");
|
|
5898
|
-
const
|
|
6084
|
+
const isPrivate = await this.folders.isPrivate(folderOf(MEDIA, data));
|
|
6085
|
+
const storage = this.storageFor(isPrivate);
|
|
6086
|
+
const filename = withPrivacy(storageKey(file.name, mimeType, random2), isPrivate);
|
|
5899
6087
|
const stored = [];
|
|
5900
6088
|
try {
|
|
5901
|
-
await
|
|
6089
|
+
await storage.put(filename, file.data, { contentType: mimeType });
|
|
5902
6090
|
stored.push(filename);
|
|
5903
6091
|
const dimensions = imageDimensions(file.data, mimeType);
|
|
5904
|
-
const sizes = await this.resizeImage(
|
|
6092
|
+
const sizes = await this.resizeImage(
|
|
6093
|
+
file.data,
|
|
6094
|
+
mimeType,
|
|
6095
|
+
filename,
|
|
6096
|
+
imageSizes,
|
|
6097
|
+
stored,
|
|
6098
|
+
storage
|
|
6099
|
+
);
|
|
5905
6100
|
const doc = await this.createDocument(
|
|
5906
6101
|
config,
|
|
5907
6102
|
{
|
|
5908
6103
|
...data,
|
|
6104
|
+
...this.folders.enabled ? { private: isPrivate } : {},
|
|
5909
6105
|
filename,
|
|
5910
6106
|
originalName: file.name.slice(0, 255),
|
|
5911
6107
|
mimeType,
|
|
@@ -5920,7 +6116,7 @@ var EasyCMS = class {
|
|
|
5920
6116
|
);
|
|
5921
6117
|
return doc;
|
|
5922
6118
|
} catch (error) {
|
|
5923
|
-
for (const key2 of stored) await
|
|
6119
|
+
for (const key2 of stored) await storage.delete(key2).catch(() => {
|
|
5924
6120
|
});
|
|
5925
6121
|
throw error;
|
|
5926
6122
|
}
|
|
@@ -5995,9 +6191,137 @@ var EasyCMS = class {
|
|
|
5995
6191
|
}
|
|
5996
6192
|
/** Public URL of a stored file. */
|
|
5997
6193
|
mediaURL(key2) {
|
|
6194
|
+
const base = `${this.config.serverURL?.replace(/\/+$/, "") ?? ""}${this.config.routes.api}`;
|
|
6195
|
+
if (isPrivateKey(key2)) return `${base}/media/private/${encodeURIComponent(key2)}`;
|
|
5998
6196
|
const custom = this.storage.url?.(key2);
|
|
5999
6197
|
if (custom) return custom;
|
|
6000
|
-
return `${
|
|
6198
|
+
return `${base}/media/file/${encodeURIComponent(key2)}`;
|
|
6199
|
+
}
|
|
6200
|
+
/**
|
|
6201
|
+
* A link to a private file that works without signing in until it expires: `expiresIn` in
|
|
6202
|
+
* seconds or as `'30m'`, `'1h'` (the default), `'7d'` (the most). Public files get their usual
|
|
6203
|
+
* URL. `size`: one of the file's resized copies. Links stop working when `secret` changes or
|
|
6204
|
+
* the file stops being private.
|
|
6205
|
+
*/
|
|
6206
|
+
signedMediaURL(doc, options = {}) {
|
|
6207
|
+
const sizes = doc.sizes ?? {};
|
|
6208
|
+
const key2 = options.size ? sizes[options.size]?.filename : doc.filename;
|
|
6209
|
+
if (typeof key2 !== "string" || !key2)
|
|
6210
|
+
throw new QueryError(
|
|
6211
|
+
options.size ? `The file has no "${options.size}" size` : "The document is not a file"
|
|
6212
|
+
);
|
|
6213
|
+
if (!isPrivateKey(key2)) return this.mediaURL(key2);
|
|
6214
|
+
const seconds = durationSeconds(options.expiresIn ?? "1h");
|
|
6215
|
+
const expires = Math.floor(Date.now() / 1e3) + seconds;
|
|
6216
|
+
return `${this.mediaURL(key2)}?expires=${expires}&signature=${this.mediaSignature(key2, expires)}`;
|
|
6217
|
+
}
|
|
6218
|
+
/** Whether a signed link to a private file is genuine and not expired. */
|
|
6219
|
+
verifyMediaSignature(key2, expires, signature) {
|
|
6220
|
+
const at = Number(expires);
|
|
6221
|
+
if (!signature || !Number.isInteger(at) || at < Date.now() / 1e3) return false;
|
|
6222
|
+
const expected = Buffer.from(this.mediaSignature(key2, at));
|
|
6223
|
+
const given = Buffer.from(signature);
|
|
6224
|
+
return expected.length === given.length && timingSafeEqual4(expected, given);
|
|
6225
|
+
}
|
|
6226
|
+
mediaSignature(key2, expires) {
|
|
6227
|
+
return createHmac5("sha256", this.config.secret).update(`media:${key2}:${expires}`).digest("base64url");
|
|
6228
|
+
}
|
|
6229
|
+
/**
|
|
6230
|
+
* How many documents use any of these files in a top-level upload field (rich text and fields
|
|
6231
|
+
* inside groups, arrays and blocks are not counted). Drafts count too.
|
|
6232
|
+
*/
|
|
6233
|
+
async mediaUsage(ids) {
|
|
6234
|
+
if (ids.length === 0) return 0;
|
|
6235
|
+
let count = 0;
|
|
6236
|
+
for (const config of this.config.collections) {
|
|
6237
|
+
if (INTERNAL_COLLECTIONS.has(config.slug)) continue;
|
|
6238
|
+
for (const field of config.fields) {
|
|
6239
|
+
if (field.type !== "upload") continue;
|
|
6240
|
+
count += await this.db.count({
|
|
6241
|
+
collection: config.slug,
|
|
6242
|
+
where: { [field.name]: { in: [...ids] } }
|
|
6243
|
+
});
|
|
6244
|
+
}
|
|
6245
|
+
}
|
|
6246
|
+
return count;
|
|
6247
|
+
}
|
|
6248
|
+
/** The storage for public files, or for private ones. */
|
|
6249
|
+
storageFor(isPrivate) {
|
|
6250
|
+
if (!isPrivate) return this.storage;
|
|
6251
|
+
if (!this.privateStorage)
|
|
6252
|
+
throw new EasyCMSError(
|
|
6253
|
+
"Private folders need upload.privateStorage: the storage has public URLs, so private files must be kept elsewhere",
|
|
6254
|
+
500
|
|
6255
|
+
);
|
|
6256
|
+
return this.privateStorage;
|
|
6257
|
+
}
|
|
6258
|
+
/**
|
|
6259
|
+
* Copies a file and its resized copies to the other storage under their new names (`.private`
|
|
6260
|
+
* added or removed). Returns the fields to save, and `cleanUp` to delete the old copies once
|
|
6261
|
+
* the document points to the new ones.
|
|
6262
|
+
*/
|
|
6263
|
+
async relocate(doc, toPrivate) {
|
|
6264
|
+
const from = this.storageFor(doc.private === true);
|
|
6265
|
+
const to = this.storageFor(toPrivate);
|
|
6266
|
+
const contentType = String(doc.mimeType ?? "application/octet-stream");
|
|
6267
|
+
const filename = withPrivacy(String(doc.filename), toPrivate);
|
|
6268
|
+
const old = [String(doc.filename)];
|
|
6269
|
+
const copy = async (source, target) => {
|
|
6270
|
+
const file = await from.get(source);
|
|
6271
|
+
if (file) await to.put(target, file.body, { contentType });
|
|
6272
|
+
};
|
|
6273
|
+
await copy(String(doc.filename), filename);
|
|
6274
|
+
const sizes = {};
|
|
6275
|
+
for (const [name, size] of Object.entries(
|
|
6276
|
+
doc.sizes ?? {}
|
|
6277
|
+
)) {
|
|
6278
|
+
const key2 = sizeKey(filename, name);
|
|
6279
|
+
await copy(String(size.filename), key2);
|
|
6280
|
+
old.push(String(size.filename));
|
|
6281
|
+
sizes[name] = { ...size, filename: key2 };
|
|
6282
|
+
}
|
|
6283
|
+
return {
|
|
6284
|
+
patch: { filename, sizes, private: toPrivate },
|
|
6285
|
+
cleanUp: async () => {
|
|
6286
|
+
for (const key2 of old) await from.delete(key2).catch(() => {
|
|
6287
|
+
});
|
|
6288
|
+
}
|
|
6289
|
+
};
|
|
6290
|
+
}
|
|
6291
|
+
/** A folder's name, parent and permissions; private only with somewhere private to keep files. */
|
|
6292
|
+
async validateFolder(data, self) {
|
|
6293
|
+
await this.folders.validate(data, self);
|
|
6294
|
+
if (data.private === true && !this.privateStorage)
|
|
6295
|
+
throw new ValidationError(MEDIA_FOLDERS, [
|
|
6296
|
+
{
|
|
6297
|
+
field: "private",
|
|
6298
|
+
message: "needs upload.privateStorage: the storage gives files public URLs, so private files must be kept elsewhere"
|
|
6299
|
+
}
|
|
6300
|
+
]);
|
|
6301
|
+
}
|
|
6302
|
+
/** After a folder became private or public (or moved): its files follow, in every subfolder. */
|
|
6303
|
+
async settlePrivacy(folder) {
|
|
6304
|
+
for (const f of await this.folders.subtree(folder))
|
|
6305
|
+
await this.settleFiles({ folder: { equals: f.id } }, f.private);
|
|
6306
|
+
}
|
|
6307
|
+
/** Files at the top level are public. */
|
|
6308
|
+
async settleTopLevel() {
|
|
6309
|
+
await this.settleFiles({ folder: { exists: false } }, false);
|
|
6310
|
+
}
|
|
6311
|
+
async settleFiles(where, isPrivate) {
|
|
6312
|
+
const found = await this.db.find({
|
|
6313
|
+
collection: MEDIA,
|
|
6314
|
+
where: andWhere(where, isPrivate ? PUBLIC_FILES : { private: { equals: true } }),
|
|
6315
|
+
sort: [],
|
|
6316
|
+
limit: 0,
|
|
6317
|
+
page: 1
|
|
6318
|
+
});
|
|
6319
|
+
for (const doc of found.docs) {
|
|
6320
|
+
const moved = await this.relocate(doc, isPrivate);
|
|
6321
|
+
const { id, ...rest } = doc;
|
|
6322
|
+
await this.db.update({ collection: MEDIA, id, data: { ...rest, ...moved.patch } });
|
|
6323
|
+
await moved.cleanUp();
|
|
6324
|
+
}
|
|
6001
6325
|
}
|
|
6002
6326
|
async update(collection2, id, data, options = {}) {
|
|
6003
6327
|
return await this.updateDocument(collection2, id, asObject(data, collection2), options);
|
|
@@ -6279,13 +6603,26 @@ var EasyCMS = class {
|
|
|
6279
6603
|
}
|
|
6280
6604
|
if (config.slug === USERS) await this.guardLastAdmin(parsed, existing, prepared);
|
|
6281
6605
|
if (password !== void 0) prepared.passwordHash = await hashPassword(password);
|
|
6282
|
-
if (collection2 === MEDIA_FOLDERS) await this.
|
|
6606
|
+
if (collection2 === MEDIA_FOLDERS) await this.validateFolder(prepared, parsed);
|
|
6607
|
+
let moved;
|
|
6608
|
+
if (collection2 === MEDIA && this.folders.enabled) {
|
|
6609
|
+
const toPrivate = await this.folders.isPrivate(folderOf(MEDIA, prepared));
|
|
6610
|
+
if (toPrivate !== (existing.private === true)) {
|
|
6611
|
+
moved = await this.relocate(existing, toPrivate);
|
|
6612
|
+
Object.assign(prepared, moved.patch);
|
|
6613
|
+
}
|
|
6614
|
+
}
|
|
6283
6615
|
const doc = await this.db.update({
|
|
6284
6616
|
collection: collection2,
|
|
6285
6617
|
id: parsed,
|
|
6286
6618
|
data: { ...prepared, createdAt: existing.createdAt, updatedAt: now }
|
|
6287
6619
|
});
|
|
6288
|
-
if (
|
|
6620
|
+
if (moved) await moved.cleanUp();
|
|
6621
|
+
if (collection2 === MEDIA_FOLDERS) {
|
|
6622
|
+
this.folders.invalidate();
|
|
6623
|
+
if (prepared.private !== existing.private || String(prepared.parent ?? null) !== String(existing.parent ?? null))
|
|
6624
|
+
await this.settlePrivacy(parsed);
|
|
6625
|
+
}
|
|
6289
6626
|
if (!live) await this.saveVersion(config, collectionParent(collection2), parsed, doc, guard);
|
|
6290
6627
|
if (password !== void 0) await this.auth.revokeSessions(parsed);
|
|
6291
6628
|
await this.notify(config.hooks?.afterChange, "afterChange", config.slug, {
|
|
@@ -6330,7 +6667,12 @@ var EasyCMS = class {
|
|
|
6330
6667
|
}
|
|
6331
6668
|
if (collection2 === MEDIA_FOLDERS) await this.folders.release(existing);
|
|
6332
6669
|
await this.db.delete({ collection: collection2, id: parsed });
|
|
6333
|
-
if (collection2 === MEDIA_FOLDERS)
|
|
6670
|
+
if (collection2 === MEDIA_FOLDERS) {
|
|
6671
|
+
this.folders.invalidate();
|
|
6672
|
+
const parent = existing.parent ?? null;
|
|
6673
|
+
if (parent !== null) await this.settlePrivacy(parent);
|
|
6674
|
+
else await this.settleTopLevel();
|
|
6675
|
+
}
|
|
6334
6676
|
if (versionLimit(config)) await this.versions.deleteAll(collectionParent(collection2), parsed);
|
|
6335
6677
|
if (config.schedule) {
|
|
6336
6678
|
for (const job of await this.pendingJobs(collection2, parsed))
|
|
@@ -7074,7 +7416,7 @@ var EasyCMS = class {
|
|
|
7074
7416
|
prepared
|
|
7075
7417
|
);
|
|
7076
7418
|
if (password !== void 0) prepared.passwordHash = await hashPassword(password);
|
|
7077
|
-
if (collection2 === MEDIA_FOLDERS) await this.
|
|
7419
|
+
if (collection2 === MEDIA_FOLDERS) await this.validateFolder(prepared, void 0);
|
|
7078
7420
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
7079
7421
|
const doc = await this.db.create({
|
|
7080
7422
|
collection: collection2,
|
|
@@ -7234,7 +7576,7 @@ var EasyCMS = class {
|
|
|
7234
7576
|
}
|
|
7235
7577
|
}
|
|
7236
7578
|
/** Writes resized copies with sharp, when installed and configured. */
|
|
7237
|
-
async resizeImage(data, mimeType, filename, sizes, stored) {
|
|
7579
|
+
async resizeImage(data, mimeType, filename, sizes, stored, storage = this.storage) {
|
|
7238
7580
|
if (sizes.length === 0 || !["image/png", "image/jpeg", "image/webp", "image/avif"].includes(mimeType))
|
|
7239
7581
|
return {};
|
|
7240
7582
|
const sharp = await loadSharp();
|
|
@@ -7252,8 +7594,8 @@ var EasyCMS = class {
|
|
|
7252
7594
|
fit: size.fit ?? "cover",
|
|
7253
7595
|
withoutEnlargement: true
|
|
7254
7596
|
}).toBuffer({ resolveWithObject: true });
|
|
7255
|
-
const key2 = filename
|
|
7256
|
-
await
|
|
7597
|
+
const key2 = sizeKey(filename, size.name);
|
|
7598
|
+
await storage.put(key2, new Uint8Array(out), { contentType: mimeType });
|
|
7257
7599
|
stored.push(key2);
|
|
7258
7600
|
result[size.name] = {
|
|
7259
7601
|
filename: key2,
|
|
@@ -7421,6 +7763,14 @@ var EasyCMS = class {
|
|
|
7421
7763
|
message: images ? `must be an image (${doc.filename ?? ref.id} is ${type || "not one"})` : `must be ${ref.mimeTypes.join(", ")} (${doc.filename ?? ref.id} is ${type || "unknown"})`
|
|
7422
7764
|
});
|
|
7423
7765
|
}
|
|
7766
|
+
if (ref.folderOnly && this.folders.enabled && !await this.folders.inKeyed(
|
|
7767
|
+
ref.folderOnly,
|
|
7768
|
+
doc.folder ?? null
|
|
7769
|
+
))
|
|
7770
|
+
errors.push({
|
|
7771
|
+
field: ref.field,
|
|
7772
|
+
message: `must be a file from the "${ref.folderOnly}" folder (${doc.filename ?? ref.id} is not)`
|
|
7773
|
+
});
|
|
7424
7774
|
}
|
|
7425
7775
|
}
|
|
7426
7776
|
return errors;
|
|
@@ -7487,6 +7837,15 @@ function folderOf(collection2, data) {
|
|
|
7487
7837
|
const value = data[collection2 === MEDIA ? "folder" : "parent"];
|
|
7488
7838
|
return value === void 0 || value === "" ? null : value;
|
|
7489
7839
|
}
|
|
7840
|
+
var MAX_LINK = 7 * 24 * 60 * 60;
|
|
7841
|
+
function durationSeconds(value) {
|
|
7842
|
+
const match = typeof value === "string" ? /^(\d+)\s*([smhd])$/.exec(value.trim()) : null;
|
|
7843
|
+
const seconds = typeof value === "number" ? value : match ? Number(match[1]) * { s: 1, m: 60, h: 3600, d: 86400 }[match[2]] : Number.NaN;
|
|
7844
|
+
if (!Number.isFinite(seconds) || seconds <= 0)
|
|
7845
|
+
throw new QueryError(`expiresIn must be seconds or like '30m', '1h', '7d' (got ${value})`);
|
|
7846
|
+
if (seconds > MAX_LINK) throw new QueryError("expiresIn can be at most 7 days");
|
|
7847
|
+
return Math.floor(seconds);
|
|
7848
|
+
}
|
|
7490
7849
|
|
|
7491
7850
|
// src/rest/admin-modules.ts
|
|
7492
7851
|
import { createHash as createHash3 } from "crypto";
|
|
@@ -7735,7 +8094,7 @@ function componentRef(component) {
|
|
|
7735
8094
|
async function allowed(access, user) {
|
|
7736
8095
|
return await evaluateAccess(access, { user }) !== false;
|
|
7737
8096
|
}
|
|
7738
|
-
async function serializeFields(fields, checks, localized) {
|
|
8097
|
+
async function serializeFields(fields, checks, localized, folderId) {
|
|
7739
8098
|
const { update: update2 } = checks;
|
|
7740
8099
|
const out = [];
|
|
7741
8100
|
for (const field of fields) {
|
|
@@ -7788,11 +8147,13 @@ async function serializeFields(fields, checks, localized) {
|
|
|
7788
8147
|
if (field.minRows !== void 0) f.minRows = field.minRows;
|
|
7789
8148
|
if (field.maxRows !== void 0) f.maxRows = field.maxRows;
|
|
7790
8149
|
if (field.mimeTypes) f.mimeTypes = expandMimeTypes(field.mimeTypes);
|
|
8150
|
+
if (field.folder && folderId)
|
|
8151
|
+
f.folder = { id: await folderId(field.folder), only: field.folderOnly === true };
|
|
7791
8152
|
break;
|
|
7792
8153
|
case "array":
|
|
7793
8154
|
if (field.minRows !== void 0) f.minRows = field.minRows;
|
|
7794
8155
|
if (field.maxRows !== void 0) f.maxRows = field.maxRows;
|
|
7795
|
-
f.fields = await serializeFields(field.fields, checks, localized);
|
|
8156
|
+
f.fields = await serializeFields(field.fields, checks, localized, folderId);
|
|
7796
8157
|
break;
|
|
7797
8158
|
case "blocks":
|
|
7798
8159
|
if (field.minRows !== void 0) f.minRows = field.minRows;
|
|
@@ -7801,12 +8162,12 @@ async function serializeFields(fields, checks, localized) {
|
|
|
7801
8162
|
field.blocks.map(async (block) => ({
|
|
7802
8163
|
slug: block.slug,
|
|
7803
8164
|
...block.labels ? { labels: block.labels } : {},
|
|
7804
|
-
fields: await serializeFields(block.fields, checks, localized)
|
|
8165
|
+
fields: await serializeFields(block.fields, checks, localized, folderId)
|
|
7805
8166
|
}))
|
|
7806
8167
|
);
|
|
7807
8168
|
break;
|
|
7808
8169
|
case "group":
|
|
7809
|
-
f.fields = await serializeFields(field.fields, checks, localized);
|
|
8170
|
+
f.fields = await serializeFields(field.fields, checks, localized, folderId);
|
|
7810
8171
|
break;
|
|
7811
8172
|
}
|
|
7812
8173
|
out.push(f);
|
|
@@ -7825,7 +8186,12 @@ async function may(cms, access, user, target, operation) {
|
|
|
7825
8186
|
}
|
|
7826
8187
|
async function collection(cms, config, user, localized, roles) {
|
|
7827
8188
|
const target = { collection: config.slug };
|
|
7828
|
-
const fields = await serializeFields(
|
|
8189
|
+
const fields = await serializeFields(
|
|
8190
|
+
config.fields,
|
|
8191
|
+
await checkers(cms, user),
|
|
8192
|
+
localized,
|
|
8193
|
+
folderIds(cms)
|
|
8194
|
+
);
|
|
7829
8195
|
if (config.slug === USERS && cms.roles.enabled) {
|
|
7830
8196
|
const role = fields.find((f) => f.name === "role");
|
|
7831
8197
|
if (role) {
|
|
@@ -7870,7 +8236,7 @@ async function global(cms, config, user, localized) {
|
|
|
7870
8236
|
versions: Boolean(config.versions),
|
|
7871
8237
|
preview: typeof config.preview === "function",
|
|
7872
8238
|
schedule: config.schedule === true,
|
|
7873
|
-
fields: await serializeFields(config.fields, checks, localized),
|
|
8239
|
+
fields: await serializeFields(config.fields, checks, localized, folderIds(cms)),
|
|
7874
8240
|
permissions: {
|
|
7875
8241
|
read: await may(cms, config.access?.read, user, target, "read"),
|
|
7876
8242
|
update: await may(cms, config.access?.update, user, target, "update"),
|
|
@@ -7915,7 +8281,13 @@ async function adminSchema(cms, user, origin) {
|
|
|
7915
8281
|
maxFileSize: cms.config.upload.maxFileSize,
|
|
7916
8282
|
mimeTypes: expandMimeTypes(cms.config.upload.mimeTypes)
|
|
7917
8283
|
},
|
|
7918
|
-
...cms.folders.enabled ? {
|
|
8284
|
+
...cms.folders.enabled ? {
|
|
8285
|
+
folders: {
|
|
8286
|
+
permissions: admin && cms.roles.enabled,
|
|
8287
|
+
// Admins can make folders private when there is somewhere private to keep files.
|
|
8288
|
+
private: admin && cms.privateStorage !== null
|
|
8289
|
+
}
|
|
8290
|
+
} : {},
|
|
7919
8291
|
views,
|
|
7920
8292
|
rbac: cms.roles.enabled,
|
|
7921
8293
|
providers: cms.auth.sso.providers(),
|
|
@@ -7962,9 +8334,12 @@ async function widgets(cms, user) {
|
|
|
7962
8334
|
}
|
|
7963
8335
|
return out;
|
|
7964
8336
|
}
|
|
8337
|
+
function folderIds(cms) {
|
|
8338
|
+
return cms.folders.enabled ? (key2) => cms.folders.keyed(key2) : void 0;
|
|
8339
|
+
}
|
|
7965
8340
|
|
|
7966
8341
|
// src/version.ts
|
|
7967
|
-
var VERSION = true ? "0.
|
|
8342
|
+
var VERSION = true ? "0.40.0" : "0.0.0-dev";
|
|
7968
8343
|
|
|
7969
8344
|
// src/rest/admin-status.ts
|
|
7970
8345
|
var DAY2 = 864e5;
|
|
@@ -8533,6 +8908,12 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
8533
8908
|
if (parsed === void 0) throw new HttpError("Not found", 404);
|
|
8534
8909
|
return { body: await cms.roles.owned(parsed) };
|
|
8535
8910
|
}
|
|
8911
|
+
if (first === "admin" && second === "media-usage" && segments.length === 2) {
|
|
8912
|
+
if (!ctx.user || ctx.user.apiKey) throw new UnauthorizedError();
|
|
8913
|
+
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
8914
|
+
const ids = (ctx.url.searchParams.get("ids") ?? "").split(",").map((id2) => parseId(id2)).filter((id2) => id2 !== void 0);
|
|
8915
|
+
return { body: { count: await cms.mediaUsage(ids) } };
|
|
8916
|
+
}
|
|
8536
8917
|
if (first === "admin" && second === "roles") {
|
|
8537
8918
|
if (!ctx.user) throw new UnauthorizedError();
|
|
8538
8919
|
if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
|
|
@@ -8679,6 +9060,10 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
8679
9060
|
if (method !== "GET" && method !== "HEAD") throw methodNotAllowed(ctx, "GET, HEAD");
|
|
8680
9061
|
return { body: await serveFile(cms, third, method === "HEAD") };
|
|
8681
9062
|
}
|
|
9063
|
+
if (first === MEDIA && second === "private" && third !== void 0 && segments.length === 3) {
|
|
9064
|
+
if (method !== "GET" && method !== "HEAD") throw methodNotAllowed(ctx, "GET, HEAD");
|
|
9065
|
+
return { body: await servePrivateFile(cms, ctx, third, method === "HEAD") };
|
|
9066
|
+
}
|
|
8682
9067
|
if (first === MEDIA && second === void 0 && method === "POST") {
|
|
8683
9068
|
if ((ctx.request.headers.get("content-type") ?? "").toLowerCase().startsWith("application/json")) {
|
|
8684
9069
|
const { url, ...data2 } = await readJson(ctx.request);
|
|
@@ -8978,6 +9363,48 @@ async function serveFile(cms, key2, head) {
|
|
|
8978
9363
|
}
|
|
8979
9364
|
);
|
|
8980
9365
|
}
|
|
9366
|
+
async function servePrivateFile(cms, ctx, key2, head) {
|
|
9367
|
+
if (!PRIVATE_KEY.test(key2) || !cms.privateStorage) throw new HttpError("Not found", 404);
|
|
9368
|
+
const query = ctx.url.searchParams;
|
|
9369
|
+
const signed = cms.verifyMediaSignature(key2, query.get("expires"), query.get("signature"));
|
|
9370
|
+
if (!signed) {
|
|
9371
|
+
const token = /-([0-9a-f]{8})\.private[.-]/.exec(key2)?.[1];
|
|
9372
|
+
if (!ctx.user || !token) throw new HttpError("Not found", 404);
|
|
9373
|
+
const found = await cms.find(MEDIA, {
|
|
9374
|
+
where: { filename: { like: `-${token}.private.` } },
|
|
9375
|
+
limit: 5,
|
|
9376
|
+
depth: 0,
|
|
9377
|
+
user: ctx.user,
|
|
9378
|
+
overrideAccess: false
|
|
9379
|
+
});
|
|
9380
|
+
const owns = found.docs.some(
|
|
9381
|
+
(d) => d.filename === key2 || Object.values(d.sizes ?? {}).some(
|
|
9382
|
+
(s) => s.filename === key2
|
|
9383
|
+
)
|
|
9384
|
+
);
|
|
9385
|
+
if (!owns) throw new HttpError("Not found", 404);
|
|
9386
|
+
}
|
|
9387
|
+
const file = await cms.privateStorage.get(key2);
|
|
9388
|
+
if (!file) throw new HttpError("Not found", 404);
|
|
9389
|
+
const type = TYPE_BY_EXTENSION[key2.slice(key2.lastIndexOf(".") + 1)] ?? "application/octet-stream";
|
|
9390
|
+
return new Response(
|
|
9391
|
+
head ? null : file.body,
|
|
9392
|
+
{
|
|
9393
|
+
status: 200,
|
|
9394
|
+
headers: {
|
|
9395
|
+
"content-type": type,
|
|
9396
|
+
"content-length": String(file.size),
|
|
9397
|
+
// Not in shared caches; a short while in the browser.
|
|
9398
|
+
"cache-control": "private, max-age=300",
|
|
9399
|
+
"x-content-type-options": "nosniff",
|
|
9400
|
+
...type === "application/pdf" ? {} : {
|
|
9401
|
+
"content-security-policy": "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox"
|
|
9402
|
+
},
|
|
9403
|
+
"cross-origin-resource-policy": "cross-origin"
|
|
9404
|
+
}
|
|
9405
|
+
}
|
|
9406
|
+
);
|
|
9407
|
+
}
|
|
8981
9408
|
async function readUpload(request, maxFileSize) {
|
|
8982
9409
|
const type = request.headers.get("content-type") ?? "";
|
|
8983
9410
|
if (!type.toLowerCase().startsWith("multipart/form-data")) {
|