@easy-cms/core 0.37.2 → 0.38.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
@@ -971,6 +971,61 @@ declare class Roles {
971
971
  private toRole;
972
972
  }
973
973
 
974
+ /** What a role may do in a folder (`upload.folders` with `auth.rbac`), each including the last. */
975
+ declare const FOLDER_LEVELS: readonly ["view", "edit", "manage"];
976
+ /**
977
+ * `view`: see its files and use them in documents. `edit`: also upload, rename, move and delete
978
+ * files. `manage`: also create, rename, move and delete subfolders.
979
+ */
980
+ type FolderLevel = (typeof FOLDER_LEVELS)[number];
981
+ /** A folder's `permissions`: each role's level; roles not listed get nothing. */
982
+ type FolderPermissions = Readonly<Record<string, FolderLevel>>;
983
+ /**
984
+ * Folders of the media library and their permissions. A folder without its own permissions
985
+ * follows its parent; the top level is open to every role that may use `media`. Folder
986
+ * permissions only narrow what roles may do with `media`, for signed-in users (not API keys).
987
+ */
988
+ declare class MediaFolders {
989
+ private readonly db;
990
+ private readonly roles;
991
+ readonly enabled: boolean;
992
+ private cache;
993
+ constructor(config: ResolvedConfig, db: Database, roles: Roles);
994
+ /** Whether folder permissions apply to this user. */
995
+ applies(user: AuthUser | null): user is AuthUser;
996
+ /** Forgets cached folders, after one changed. */
997
+ invalidate(): void;
998
+ private rows;
999
+ /** A role's level in a folder (`null`: the top level): the nearest permissions up the tree. */
1000
+ private levelIn;
1001
+ /** The user's level in a folder (`null`: the top level). */
1002
+ level(user: AuthUser | null, id: ID | null): Promise<FolderLevel | 'none'>;
1003
+ /**
1004
+ * The constraint folder permissions add to an operation on files or folders, or `true` when
1005
+ * they add none (no folder limits this user).
1006
+ */
1007
+ where(user: AuthUser | null, collection: string, operation: string): Promise<true | Where>;
1008
+ /** Refuses putting a file (`media`) or a subfolder in a folder the user may not. */
1009
+ checkTarget(user: AuthUser | null, collection: string, folder: ID | null): Promise<void>;
1010
+ /**
1011
+ * Checks a folder before it is saved: a name unique among its siblings (ignoring case), a
1012
+ * parent that is not itself or inside it, and permissions for roles that exist.
1013
+ */
1014
+ validate(data: Record<string, unknown>, self: ID | undefined): Promise<void>;
1015
+ /**
1016
+ * Before a folder is deleted: its files and subfolders move up to its parent. Returns how many
1017
+ * moved.
1018
+ */
1019
+ release(folder: {
1020
+ id: ID;
1021
+ parent?: unknown;
1022
+ }): Promise<{
1023
+ files: number;
1024
+ folders: number;
1025
+ }>;
1026
+ private fresh;
1027
+ }
1028
+
974
1029
  interface StoredFile {
975
1030
  readonly body: Uint8Array;
976
1031
  readonly size: number;
@@ -1116,6 +1171,8 @@ declare class EasyCMS<C extends Config = Config> {
1116
1171
  readonly roles: Roles;
1117
1172
  /** Who did what (`audit`, Settings → Audit log). */
1118
1173
  readonly audit: AuditLog;
1174
+ /** Folders of the media library and their permissions (`upload.folders`). */
1175
+ readonly folders: MediaFolders;
1119
1176
  private readonly versions;
1120
1177
  private readonly webhooks;
1121
1178
  private readonly mailer;
@@ -2186,6 +2243,11 @@ interface UploadConfig {
2186
2243
  readonly imageSizes?: readonly ImageSize[];
2187
2244
  /** Lets users upload from a link. Off by default. */
2188
2245
  readonly fromURL?: UploadFromURLConfig;
2246
+ /**
2247
+ * Folders in the media library (`media-folders`; a file is in one folder at most). With
2248
+ * `auth.rbac`, admins can also choose which roles see and change each folder. Off by default.
2249
+ */
2250
+ readonly folders?: boolean;
2189
2251
  }
2190
2252
  interface RoutesConfig {
2191
2253
  /** Where the REST API is served. Default `/api/cms`. */
@@ -2538,6 +2600,7 @@ interface AdminBackups {
2538
2600
  declare function writeBackupFile<C extends Config>(cms: EasyCMS<C>, file: string): Promise<void>;
2539
2601
 
2540
2602
  declare const MEDIA = "media";
2603
+ declare const MEDIA_FOLDERS = "media-folders";
2541
2604
  /** Collections Easy CMS uses internally. Not exposed over REST or in the admin UI. */
2542
2605
  declare const INTERNAL_COLLECTIONS: ReadonlySet<string>;
2543
2606
 
@@ -2886,6 +2949,13 @@ interface AdminSchema {
2886
2949
  modules: string[];
2887
2950
  /** Files can be uploaded from links (`upload.fromURL`). */
2888
2951
  uploadFromURL: boolean;
2952
+ /**
2953
+ * The media library has folders (`upload.folders`); `permissions`: this user may choose which
2954
+ * roles use each folder (admins, with `auth.rbac`).
2955
+ */
2956
+ folders?: {
2957
+ permissions: boolean;
2958
+ };
2889
2959
  /** Which saved deliveries the admin can show (webhooks, emails), for users who may see them. */
2890
2960
  deliveries?: {
2891
2961
  webhook: boolean;
@@ -3019,4 +3089,4 @@ declare function validateConfig(config: Config): ConfigIssue[];
3019
3089
  /** The version of `@easy-cms/core`, set when the package is built. */
3020
3090
  declare const VERSION: string;
3021
3091
 
3022
- 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, 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, ForbiddenError, type GlobalAccess, type GlobalConfig, type GlobalDocument, type GlobalHooks, type GlobalInput, type GlobalSlug, type GroupField, type ID, INTERNAL_COLLECTIONS, type ImageSize, type InferCollection, type InferGlobal, type JsonField, type Label, type LivePreview, type LoadConfigOptions, type LocalStorageOptions, type LocalizationConfig, type Logger, type LoginArgs, MAX_DEPTH, MEDIA, MIN_PASSWORD_LENGTH, MIN_SECRET_LENGTH, type MediaDocument, type MediaSize, type MigrationInfo, NotFoundError, type NumberField, type Operation, type PaginatedDocs, type PasswordEmail, type PasswordEmailArgs, type PasswordEmailFn, type PasswordLinkOptions, PayloadTooLargeError, type Plugin, type 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, 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 };
3092
+ 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, 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, 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
@@ -136,6 +136,7 @@ import { createHmac } from "crypto";
136
136
  // src/builtins.ts
137
137
  var USERS = "users";
138
138
  var MEDIA = "media";
139
+ var MEDIA_FOLDERS = "media-folders";
139
140
  var SESSIONS = "sessions";
140
141
  var LOGIN_ATTEMPTS = "login-attempts";
141
142
  var VERSIONS = "document-versions";
@@ -285,15 +286,72 @@ function mediaFields() {
285
286
  { name: "alt", type: "text", label: { en: "Alternative text", th: "\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21\u0E41\u0E17\u0E19\u0E23\u0E39\u0E1B" } }
286
287
  ];
287
288
  }
289
+ var adminField = {
290
+ read: ({ user }) => user?.role === "admin" && !user.apiKey,
291
+ update: ({ user }) => user?.role === "admin" && !user.apiKey
292
+ };
293
+ var mediaFoldersCollection = {
294
+ slug: MEDIA_FOLDERS,
295
+ labels: {
296
+ singular: { en: "Folder", th: "\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C" },
297
+ plural: { en: "Folders", th: "\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C" }
298
+ },
299
+ icon: "folder",
300
+ useAsTitle: "name",
301
+ admin: { list: { tree: "parent", sort: "name" } },
302
+ access: { read: isLoggedIn, create: isLoggedIn, update: isLoggedIn, delete: isLoggedIn },
303
+ hooks: {
304
+ // What the reader may do here (`view`, `edit`, `manage`), for the admin's buttons.
305
+ afterRead: [
306
+ async ({ doc, user, cms }) => ({
307
+ ...doc,
308
+ level: await cms.folders.level(user, doc.id)
309
+ })
310
+ ]
311
+ },
312
+ fields: [
313
+ {
314
+ name: "name",
315
+ type: "text",
316
+ required: true,
317
+ maxLength: 120,
318
+ label: { en: "Name", th: "\u0E0A\u0E37\u0E48\u0E2D" }
319
+ },
320
+ {
321
+ name: "parent",
322
+ type: "relationship",
323
+ to: MEDIA_FOLDERS,
324
+ index: true,
325
+ label: { en: "In folder", th: "\u0E2D\u0E22\u0E39\u0E48\u0E43\u0E19\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C" }
326
+ },
327
+ // `null`: as the parent folder. Otherwise each role's level (`view`, `edit`, `manage`);
328
+ // roles not listed get nothing.
329
+ {
330
+ name: "permissions",
331
+ type: "json",
332
+ access: adminField,
333
+ label: { en: "Permissions", th: "\u0E2A\u0E34\u0E17\u0E18\u0E34\u0E4C\u0E40\u0E02\u0E49\u0E32\u0E16\u0E36\u0E07" }
334
+ }
335
+ ]
336
+ };
337
+ var folderField = {
338
+ name: "folder",
339
+ type: "relationship",
340
+ to: MEDIA_FOLDERS,
341
+ index: true,
342
+ position: "sidebar",
343
+ label: { en: "Folder", th: "\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C" }
344
+ };
288
345
  function withMedia(config) {
289
346
  const custom = config.collections?.find((c) => c.slug === MEDIA);
347
+ const folders = config.upload?.folders === true;
290
348
  const media = {
291
349
  slug: MEDIA,
292
350
  labels: { singular: { en: "Media", th: "\u0E2A\u0E37\u0E48\u0E2D" }, plural: { en: "Media", th: "\u0E04\u0E25\u0E31\u0E07\u0E2A\u0E37\u0E48\u0E2D" } },
293
351
  icon: "image",
294
352
  useAsTitle: "filename",
295
353
  ...custom,
296
- fields: [...mediaFields(), ...custom?.fields ?? []],
354
+ fields: [...mediaFields(), ...folders ? [folderField] : [], ...custom?.fields ?? []],
297
355
  access: {
298
356
  read: anyone,
299
357
  create: isLoggedIn,
@@ -331,7 +389,15 @@ function withMedia(config) {
331
389
  }
332
390
  };
333
391
  const others = (config.collections ?? []).filter((c) => c.slug !== MEDIA);
334
- return { ...config, collections: [...others.slice(0, 1), media, ...others.slice(1)] };
392
+ return {
393
+ ...config,
394
+ collections: [
395
+ ...others.slice(0, 1),
396
+ media,
397
+ ...folders ? [mediaFoldersCollection] : [],
398
+ ...others.slice(1)
399
+ ]
400
+ };
335
401
  }
336
402
  var nobody = () => false;
337
403
  var internalCollections = [
@@ -3070,7 +3136,7 @@ var rolesCollection = {
3070
3136
  ]
3071
3137
  };
3072
3138
  function governed(slug2) {
3073
- return !INTERNAL_COLLECTIONS.has(slug2) && slug2 !== API_KEYS;
3139
+ return !INTERNAL_COLLECTIONS.has(slug2) && slug2 !== API_KEYS && slug2 !== MEDIA_FOLDERS;
3074
3140
  }
3075
3141
  function ruleFields(fields) {
3076
3142
  return fields.filter((f) => !f.hidden && f.name !== "createdBy");
@@ -4293,6 +4359,8 @@ function validateUpload(config, add) {
4293
4359
  if (upload.dir !== void 0 && (typeof upload.dir !== "string" || upload.dir.length === 0)) {
4294
4360
  add("upload.dir", "must be a non-empty string");
4295
4361
  }
4362
+ if (upload.folders !== void 0 && typeof upload.folders !== "boolean")
4363
+ add("upload.folders", "must be true or false");
4296
4364
  const fromURL = upload.fromURL;
4297
4365
  if (fromURL === void 0) return;
4298
4366
  if (typeof fromURL !== "object" || fromURL === null) {
@@ -4837,6 +4905,7 @@ async function resolveConfig(input) {
4837
4905
  maxFileSize: config.upload?.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
4838
4906
  mimeTypes: config.upload?.mimeTypes ?? ["image/*", "application/pdf"],
4839
4907
  imageSizes: config.upload?.imageSizes ?? [],
4908
+ folders: config.upload?.folders === true,
4840
4909
  ...config.upload?.storage ? { storage: config.upload.storage } : {},
4841
4910
  ...config.upload?.fromURL ? { fromURL: config.upload.fromURL } : {}
4842
4911
  },
@@ -5144,6 +5213,170 @@ function storageKey(originalName, type, random2) {
5144
5213
  return `${base}-${random2}.${EXTENSIONS[type] ?? "bin"}`;
5145
5214
  }
5146
5215
 
5216
+ // src/media-folders.ts
5217
+ var FOLDER_LEVELS = ["view", "edit", "manage"];
5218
+ var RANK = { none: 0, view: 1, edit: 2, manage: 3 };
5219
+ var TTL2 = 1e4;
5220
+ function needed(collection2, operation) {
5221
+ if (operation === "read") return "view";
5222
+ return collection2 === MEDIA ? "edit" : "manage";
5223
+ }
5224
+ var MediaFolders = class {
5225
+ constructor(config, db, roles) {
5226
+ this.db = db;
5227
+ this.roles = roles;
5228
+ this.enabled = config.upload.folders;
5229
+ }
5230
+ db;
5231
+ roles;
5232
+ enabled;
5233
+ cache;
5234
+ /** Whether folder permissions apply to this user. */
5235
+ applies(user) {
5236
+ return this.enabled && this.roles.enabled && !!user && user.role !== "admin" && !user.apiKey;
5237
+ }
5238
+ /** Forgets cached folders, after one changed. */
5239
+ invalidate() {
5240
+ this.cache = void 0;
5241
+ }
5242
+ async rows() {
5243
+ if (this.cache && Date.now() - this.cache.at < TTL2) return this.cache.rows;
5244
+ const found = await this.db.find({ collection: MEDIA_FOLDERS, sort: [], limit: 0, page: 1 });
5245
+ const rows = /* @__PURE__ */ new Map();
5246
+ for (const doc of found.docs) {
5247
+ const permissions = doc.permissions;
5248
+ rows.set(String(doc.id), {
5249
+ id: doc.id,
5250
+ parent: doc.parent ?? null,
5251
+ name: String(doc.name ?? ""),
5252
+ permissions: permissions && typeof permissions === "object" && !Array.isArray(permissions) ? permissions : null
5253
+ });
5254
+ }
5255
+ this.cache = { at: Date.now(), rows };
5256
+ return rows;
5257
+ }
5258
+ /** A role's level in a folder (`null`: the top level): the nearest permissions up the tree. */
5259
+ levelIn(rows, role, id) {
5260
+ const seen = /* @__PURE__ */ new Set();
5261
+ let current = id === null ? void 0 : rows.get(String(id));
5262
+ while (current && !seen.has(String(current.id))) {
5263
+ seen.add(String(current.id));
5264
+ if (current.permissions) {
5265
+ const level = current.permissions[role];
5266
+ return level && level in RANK ? level : "none";
5267
+ }
5268
+ current = current.parent === null ? void 0 : rows.get(String(current.parent));
5269
+ }
5270
+ return "manage";
5271
+ }
5272
+ /** The user's level in a folder (`null`: the top level). */
5273
+ async level(user, id) {
5274
+ if (!this.applies(user)) return "manage";
5275
+ return this.levelIn(await this.rows(), user.role, id);
5276
+ }
5277
+ /**
5278
+ * The constraint folder permissions add to an operation on files or folders, or `true` when
5279
+ * they add none (no folder limits this user).
5280
+ */
5281
+ async where(user, collection2, operation) {
5282
+ if (collection2 !== MEDIA && collection2 !== MEDIA_FOLDERS || !this.applies(user)) return true;
5283
+ if (operation === "create") return true;
5284
+ const need = RANK[needed(collection2, operation)];
5285
+ const rows = await this.rows();
5286
+ const allowed2 = [];
5287
+ let all = true;
5288
+ for (const row of rows.values()) {
5289
+ if (RANK[this.levelIn(rows, user.role, row.id)] >= need) allowed2.push(row.id);
5290
+ else all = false;
5291
+ }
5292
+ if (all) return true;
5293
+ if (collection2 === MEDIA_FOLDERS) return { id: { in: allowed2 } };
5294
+ const outside = { folder: { exists: false } };
5295
+ return allowed2.length === 0 ? outside : { or: [{ folder: { in: allowed2 } }, outside] };
5296
+ }
5297
+ /** Refuses putting a file (`media`) or a subfolder in a folder the user may not. */
5298
+ async checkTarget(user, collection2, folder) {
5299
+ if (!this.applies(user)) return;
5300
+ const need = collection2 === MEDIA ? "edit" : "manage";
5301
+ if (RANK[await this.level(user, folder)] < RANK[need])
5302
+ throw new ForbiddenError("You may not add to this folder");
5303
+ }
5304
+ /**
5305
+ * Checks a folder before it is saved: a name unique among its siblings (ignoring case), a
5306
+ * parent that is not itself or inside it, and permissions for roles that exist.
5307
+ */
5308
+ async validate(data, self) {
5309
+ const rows = await this.fresh();
5310
+ const name = typeof data.name === "string" ? data.name.trim() : "";
5311
+ const parent = data.parent ?? null;
5312
+ const errors = [];
5313
+ if (parent !== null && self !== void 0) {
5314
+ let current = rows.get(String(parent));
5315
+ const seen = /* @__PURE__ */ new Set();
5316
+ while (current && !seen.has(String(current.id))) {
5317
+ if (String(current.id) === String(self)) {
5318
+ errors.push({ field: "parent", message: "cannot be the folder itself or inside it" });
5319
+ break;
5320
+ }
5321
+ seen.add(String(current.id));
5322
+ current = current.parent === null ? void 0 : rows.get(String(current.parent));
5323
+ }
5324
+ }
5325
+ const taken = [...rows.values()].some(
5326
+ (row) => String(row.id) !== String(self) && String(row.parent) === String(parent) && row.name.trim().toLocaleLowerCase() === name.toLocaleLowerCase()
5327
+ );
5328
+ if (name && taken) errors.push({ field: "name", message: "is already used in this folder" });
5329
+ const permissions = data.permissions;
5330
+ if (permissions !== null && permissions !== void 0) {
5331
+ const roles = new Set((await this.roles.options()).map((r) => r.key));
5332
+ if (typeof permissions !== "object" || Array.isArray(permissions))
5333
+ errors.push({ field: "permissions", message: "must be null or roles with their levels" });
5334
+ else
5335
+ for (const [role, level] of Object.entries(permissions)) {
5336
+ if (!roles.has(role))
5337
+ errors.push({ field: "permissions", message: `"${role}" is not a role` });
5338
+ else if (!FOLDER_LEVELS.includes(level))
5339
+ errors.push({
5340
+ field: "permissions",
5341
+ message: `"${role}" must be one of ${FOLDER_LEVELS.join(", ")}`
5342
+ });
5343
+ }
5344
+ }
5345
+ if (errors.length > 0) throw new ValidationError(MEDIA_FOLDERS, errors);
5346
+ }
5347
+ /**
5348
+ * Before a folder is deleted: its files and subfolders move up to its parent. Returns how many
5349
+ * moved.
5350
+ */
5351
+ async release(folder) {
5352
+ const parent = folder.parent ?? null;
5353
+ const moved = { files: 0, folders: 0 };
5354
+ for (const [collection2, field, key2] of [
5355
+ [MEDIA, "folder", "files"],
5356
+ [MEDIA_FOLDERS, "parent", "folders"]
5357
+ ]) {
5358
+ const found = await this.db.find({
5359
+ collection: collection2,
5360
+ where: { [field]: { equals: folder.id } },
5361
+ sort: [],
5362
+ limit: 0,
5363
+ page: 1
5364
+ });
5365
+ for (const doc of found.docs) {
5366
+ const { id, ...rest } = doc;
5367
+ await this.db.update({ collection: collection2, id, data: { ...rest, [field]: parent } });
5368
+ moved[key2]++;
5369
+ }
5370
+ }
5371
+ this.invalidate();
5372
+ return moved;
5373
+ }
5374
+ async fresh() {
5375
+ this.invalidate();
5376
+ return this.rows();
5377
+ }
5378
+ };
5379
+
5147
5380
  // src/populate.ts
5148
5381
  var DEFAULT_DEPTH = 1;
5149
5382
  var MAX_DEPTH = 3;
@@ -5461,6 +5694,8 @@ var EasyCMS = class {
5461
5694
  roles;
5462
5695
  /** Who did what (`audit`, Settings → Audit log). */
5463
5696
  audit;
5697
+ /** Folders of the media library and their permissions (`upload.folders`). */
5698
+ folders;
5464
5699
  versions;
5465
5700
  webhooks;
5466
5701
  mailer;
@@ -5478,6 +5713,7 @@ var EasyCMS = class {
5478
5713
  (entry) => this.audit.record(entry)
5479
5714
  );
5480
5715
  this.audit = new AuditLog(this);
5716
+ this.folders = new MediaFolders(config, db, this.roles);
5481
5717
  this.webhooks = new Webhooks(
5482
5718
  config.webhooks ?? [],
5483
5719
  logger,
@@ -5572,6 +5808,7 @@ var EasyCMS = class {
5572
5808
  await this.checkGrant(guard, { collection: MEDIA }, "create");
5573
5809
  const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data });
5574
5810
  if (allowed2 !== true) throw deny(guard.user);
5811
+ await this.folders.checkTarget(guard.user, MEDIA, folderOf(MEDIA, data));
5575
5812
  }
5576
5813
  const { maxFileSize, mimeTypes, imageSizes } = this.config.upload;
5577
5814
  if (file.data.byteLength > maxFileSize) {
@@ -5903,6 +6140,11 @@ var EasyCMS = class {
5903
6140
  const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
5904
6141
  if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
5905
6142
  await this.checkDocumentAccess(config, "update", guard, parsed, raw);
6143
+ if (guard.enforce && inFolders(this.config, collection2)) {
6144
+ const key2 = collection2 === MEDIA ? "folder" : "parent";
6145
+ if (Object.hasOwn(raw, key2) && String(raw[key2] ?? null) !== String(existing[key2] ?? null))
6146
+ await this.folders.checkTarget(guard.user, collection2, folderOf(collection2, raw));
6147
+ }
5906
6148
  if (config.drafts && (mode === "unpublish" || raw.status === "published"))
5907
6149
  await this.checkDocumentGrant(guard, collection2, "publish", parsed);
5908
6150
  const [drafted] = await this.withDrafts(config, [existing]);
@@ -5968,11 +6210,13 @@ var EasyCMS = class {
5968
6210
  }
5969
6211
  if (config.slug === USERS) await this.guardLastAdmin(parsed, existing, prepared);
5970
6212
  if (password !== void 0) prepared.passwordHash = await hashPassword(password);
6213
+ if (collection2 === MEDIA_FOLDERS) await this.folders.validate(prepared, parsed);
5971
6214
  const doc = await this.db.update({
5972
6215
  collection: collection2,
5973
6216
  id: parsed,
5974
6217
  data: { ...prepared, createdAt: existing.createdAt, updatedAt: now }
5975
6218
  });
6219
+ if (collection2 === MEDIA_FOLDERS) this.folders.invalidate();
5976
6220
  if (!live) await this.saveVersion(config, collectionParent(collection2), parsed, doc, guard);
5977
6221
  if (password !== void 0) await this.auth.revokeSessions(parsed);
5978
6222
  await this.notify(config.hooks?.afterChange, "afterChange", config.slug, {
@@ -6015,7 +6259,9 @@ var EasyCMS = class {
6015
6259
  await this.auth.revokeSessions(parsed);
6016
6260
  await this.auth.sso.forget(parsed);
6017
6261
  }
6262
+ if (collection2 === MEDIA_FOLDERS) await this.folders.release(existing);
6018
6263
  await this.db.delete({ collection: collection2, id: parsed });
6264
+ if (collection2 === MEDIA_FOLDERS) this.folders.invalidate();
6019
6265
  if (versionLimit(config)) await this.versions.deleteAll(collectionParent(collection2), parsed);
6020
6266
  if (config.schedule) {
6021
6267
  for (const job of await this.pendingJobs(collection2, parsed))
@@ -6585,11 +6831,16 @@ var EasyCMS = class {
6585
6831
  async checkGrant(guard, target, operation) {
6586
6832
  if (!guard.enforce) return true;
6587
6833
  const name = "collection" in target ? target.collection : target.global;
6588
- if (!keyAllows(guard.user, target, operation))
6834
+ const folders = "collection" in target && target.collection === MEDIA_FOLDERS;
6835
+ const granted = folders ? { collection: MEDIA } : target;
6836
+ if (!keyAllows(guard.user, granted, operation))
6589
6837
  throw new ForbiddenError(`This API key may not ${operation} "${name}"`);
6590
- const role = await this.roles.allows(guard.user, target, operation);
6838
+ let role = await this.roles.allows(guard.user, granted, operation);
6591
6839
  if (role === false) throw deny(guard.user);
6592
- return role;
6840
+ if (folders && role !== true && guard.user) role = { createdBy: { equals: guard.user.id } };
6841
+ const inFolder = await this.folders.where(guard.user, name, operation);
6842
+ if (inFolder === true) return role;
6843
+ return role === true ? inFolder : { and: [role, inFolder] };
6593
6844
  }
6594
6845
  /** The query constraint read access adds, or `undefined` when there is none. Throws when denied. */
6595
6846
  async readWhere(config, guard, where) {
@@ -6717,6 +6968,8 @@ var EasyCMS = class {
6717
6968
  if (typeof allowed2 === "object")
6718
6969
  throw new QueryError(`create access of "${collection2}" must return a boolean`);
6719
6970
  if (!allowed2) throw deny(guard.user);
6971
+ if (inFolders(this.config, collection2))
6972
+ await this.folders.checkTarget(guard.user, collection2, folderOf(collection2, raw));
6720
6973
  }
6721
6974
  const { input, password } = splitPassword(config, raw);
6722
6975
  const filtered = await filterInput(
@@ -6752,11 +7005,13 @@ var EasyCMS = class {
6752
7005
  prepared
6753
7006
  );
6754
7007
  if (password !== void 0) prepared.passwordHash = await hashPassword(password);
7008
+ if (collection2 === MEDIA_FOLDERS) await this.folders.validate(prepared, void 0);
6755
7009
  const now = (/* @__PURE__ */ new Date()).toISOString();
6756
7010
  const doc = await this.db.create({
6757
7011
  collection: collection2,
6758
7012
  data: { ...prepared, createdAt: now, updatedAt: now }
6759
7013
  });
7014
+ if (collection2 === MEDIA_FOLDERS) this.folders.invalidate();
6760
7015
  await this.saveVersion(config, collectionParent(collection2), doc.id, doc, guard);
6761
7016
  await this.notify(config.hooks?.afterChange, "afterChange", collection2, {
6762
7017
  ...base,
@@ -6845,7 +7100,9 @@ var EasyCMS = class {
6845
7100
  async audited(config, event, doc, previous, guard, mode = "save") {
6846
7101
  if (!this.audit.enabled) return;
6847
7102
  let action = event;
6848
- if (event === "update" || event === "draft") {
7103
+ if (config.slug === MEDIA_FOLDERS && event === "update" && JSON.stringify(doc.permissions ?? null) !== JSON.stringify(previous?.permissions ?? null))
7104
+ action = "folder.permissions";
7105
+ else if (event === "update" || event === "draft") {
6849
7106
  const was = previous?.status;
6850
7107
  if (mode === "restore") action = "restore";
6851
7108
  else if (config.drafts && doc.status === "published" && was !== "published")
@@ -7154,6 +7411,13 @@ function asObject(data, name) {
7154
7411
  }
7155
7412
  return data;
7156
7413
  }
7414
+ function inFolders(config, collection2) {
7415
+ return config.upload.folders && (collection2 === MEDIA || collection2 === MEDIA_FOLDERS);
7416
+ }
7417
+ function folderOf(collection2, data) {
7418
+ const value = data[collection2 === MEDIA ? "folder" : "parent"];
7419
+ return value === void 0 || value === "" ? null : value;
7420
+ }
7157
7421
 
7158
7422
  // src/rest/admin-modules.ts
7159
7423
  import { createHash as createHash3 } from "crypto";
@@ -7578,6 +7842,7 @@ async function adminSchema(cms, user, origin) {
7578
7842
  globals: await Promise.all(cms.config.globals.map((g) => global(cms, g, user, localized))),
7579
7843
  modules: adminModuleUrls(cms),
7580
7844
  uploadFromURL: cms.config.upload.fromURL !== void 0,
7845
+ ...cms.folders.enabled ? { folders: { permissions: admin && cms.roles.enabled } } : {},
7581
7846
  views,
7582
7847
  rbac: cms.roles.enabled,
7583
7848
  providers: cms.auth.sso.providers(),
@@ -7626,7 +7891,7 @@ async function widgets(cms, user) {
7626
7891
  }
7627
7892
 
7628
7893
  // src/version.ts
7629
- var VERSION = true ? "0.37.2" : "0.0.0-dev";
7894
+ var VERSION = true ? "0.38.0" : "0.0.0-dev";
7630
7895
 
7631
7896
  // src/rest/admin-status.ts
7632
7897
  var DAY2 = 864e5;
@@ -8974,12 +9239,15 @@ export {
8974
9239
  EasyCMSError,
8975
9240
  FIELD_TYPES,
8976
9241
  FIELD_TYPE_BASES,
9242
+ FOLDER_LEVELS,
8977
9243
  ForbiddenError,
8978
9244
  INTERNAL_COLLECTIONS,
8979
9245
  MAX_DEPTH,
8980
9246
  MEDIA,
9247
+ MEDIA_FOLDERS,
8981
9248
  MIN_PASSWORD_LENGTH,
8982
9249
  MIN_SECRET_LENGTH,
9250
+ MediaFolders,
8983
9251
  NotFoundError,
8984
9252
  PayloadTooLargeError,
8985
9253
  QueryError,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "0.37.2",
3
+ "version": "0.38.0",
4
4
  "description": "Embedded, code-first headless CMS for Nuxt and Next.js",
5
5
  "license": "MIT",
6
6
  "type": "module",