@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 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
- /** Public URL of the file. */
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: [...mediaFields(), ...folders ? [folderField] : [], ...custom?.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 cms.storage.delete(key2);
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 || !this.applies(user)) return true;
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 filename = storageKey(file.name, mimeType, random2);
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 this.storage.put(filename, file.data, { contentType: mimeType });
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(file.data, mimeType, filename, imageSizes, stored);
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 this.storage.delete(key2).catch(() => {
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 `${this.config.serverURL?.replace(/\/+$/, "") ?? ""}${this.config.routes.api}/media/file/${encodeURIComponent(key2)}`;
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.folders.validate(prepared, parsed);
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 (collection2 === MEDIA_FOLDERS) this.folders.invalidate();
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) this.folders.invalidate();
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.folders.validate(prepared, void 0);
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.replace(/\.([^.]+)$/, `-${size.name}.$1`);
7256
- await this.storage.put(key2, new Uint8Array(out), { contentType: mimeType });
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(config.fields, await checkers(cms, user), localized);
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 ? { folders: { permissions: admin && cms.roles.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.39.0" : "0.0.0-dev";
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")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "description": "Embedded, code-first headless CMS for Nuxt and Next.js",
5
5
  "license": "MIT",
6
6
  "type": "module",