@easy-cms/core 0.37.2 → 0.39.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 +90 -4
- package/dist/index.js +447 -100
- package/package.json +1 -1
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
|
|
|
@@ -2642,9 +2705,20 @@ declare function loadConfig(options?: LoadConfigOptions): Promise<ResolvedConfig
|
|
|
2642
2705
|
|
|
2643
2706
|
/** File types Easy CMS recognizes from their contents (FR-UPL-03). */
|
|
2644
2707
|
declare const EXTENSIONS: Readonly<Record<string, string>>;
|
|
2645
|
-
/**
|
|
2646
|
-
|
|
2647
|
-
|
|
2708
|
+
/**
|
|
2709
|
+
* Names for several types at once, usable in `upload.mimeTypes` and an upload field's
|
|
2710
|
+
* `mimeTypes`: `documents` (PDF, Word, Excel, PowerPoint, OpenDocument, CSV, text), `office`
|
|
2711
|
+
* (docx, xlsx, pptx) and `archives` (zip).
|
|
2712
|
+
*/
|
|
2713
|
+
declare const MIME_GROUPS: Readonly<Record<string, readonly string[]>>;
|
|
2714
|
+
/** Patterns with group names (`documents`…) replaced by their types. */
|
|
2715
|
+
declare function expandMimeTypes(patterns: readonly string[]): string[];
|
|
2716
|
+
/**
|
|
2717
|
+
* Detects the MIME type from file contents, ignoring the type the client sent. The name only
|
|
2718
|
+
* tells CSV from plain text, which have the same contents.
|
|
2719
|
+
*/
|
|
2720
|
+
declare function sniffMimeType(data: Uint8Array, name?: string): string | undefined;
|
|
2721
|
+
/** `image/*` style matching; group names (`documents`…) stand for their types. */
|
|
2648
2722
|
declare function mimeAllowed(type: string, allowed: readonly string[]): boolean;
|
|
2649
2723
|
/** Width and height of PNG, GIF, JPEG and WebP images without decoding them. */
|
|
2650
2724
|
declare function imageDimensions(data: Uint8Array, type: string): {
|
|
@@ -2886,6 +2960,18 @@ interface AdminSchema {
|
|
|
2886
2960
|
modules: string[];
|
|
2887
2961
|
/** Files can be uploaded from links (`upload.fromURL`). */
|
|
2888
2962
|
uploadFromURL: boolean;
|
|
2963
|
+
/** What the media library takes (`upload.maxFileSize`, `upload.mimeTypes` with groups expanded). */
|
|
2964
|
+
upload: {
|
|
2965
|
+
maxFileSize: number;
|
|
2966
|
+
mimeTypes: string[];
|
|
2967
|
+
};
|
|
2968
|
+
/**
|
|
2969
|
+
* The media library has folders (`upload.folders`); `permissions`: this user may choose which
|
|
2970
|
+
* roles use each folder (admins, with `auth.rbac`).
|
|
2971
|
+
*/
|
|
2972
|
+
folders?: {
|
|
2973
|
+
permissions: boolean;
|
|
2974
|
+
};
|
|
2889
2975
|
/** Which saved deliveries the admin can show (webhooks, emails), for users who may see them. */
|
|
2890
2976
|
deliveries?: {
|
|
2891
2977
|
webhook: boolean;
|
|
@@ -3019,4 +3105,4 @@ declare function validateConfig(config: Config): ConfigIssue[];
|
|
|
3019
3105
|
/** The version of `@easy-cms/core`, set when the package is built. */
|
|
3020
3106
|
declare const VERSION: string;
|
|
3021
3107
|
|
|
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 };
|
|
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 };
|
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 {
|
|
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 = [
|
|
@@ -851,11 +917,162 @@ var PayloadTooLargeError = class extends EasyCMSError {
|
|
|
851
917
|
}
|
|
852
918
|
};
|
|
853
919
|
|
|
920
|
+
// src/media.ts
|
|
921
|
+
var EXTENSIONS = {
|
|
922
|
+
"image/png": "png",
|
|
923
|
+
"image/jpeg": "jpg",
|
|
924
|
+
"image/gif": "gif",
|
|
925
|
+
"image/webp": "webp",
|
|
926
|
+
"image/avif": "avif",
|
|
927
|
+
"image/svg+xml": "svg",
|
|
928
|
+
"application/pdf": "pdf",
|
|
929
|
+
"video/mp4": "mp4",
|
|
930
|
+
"video/webm": "webm",
|
|
931
|
+
"video/quicktime": "mov",
|
|
932
|
+
"audio/mpeg": "mp3",
|
|
933
|
+
"audio/wav": "wav",
|
|
934
|
+
"audio/ogg": "ogg",
|
|
935
|
+
"audio/mp4": "m4a",
|
|
936
|
+
"text/plain": "txt",
|
|
937
|
+
"text/csv": "csv",
|
|
938
|
+
"application/zip": "zip",
|
|
939
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
|
|
940
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
|
|
941
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
|
|
942
|
+
"application/vnd.oasis.opendocument.text": "odt",
|
|
943
|
+
"application/vnd.oasis.opendocument.spreadsheet": "ods",
|
|
944
|
+
"application/vnd.oasis.opendocument.presentation": "odp"
|
|
945
|
+
};
|
|
946
|
+
var OFFICE = [
|
|
947
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
948
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
949
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
|
950
|
+
];
|
|
951
|
+
var MIME_GROUPS = {
|
|
952
|
+
office: OFFICE,
|
|
953
|
+
documents: [
|
|
954
|
+
"application/pdf",
|
|
955
|
+
...OFFICE,
|
|
956
|
+
"application/vnd.oasis.opendocument.text",
|
|
957
|
+
"application/vnd.oasis.opendocument.spreadsheet",
|
|
958
|
+
"application/vnd.oasis.opendocument.presentation",
|
|
959
|
+
"text/csv",
|
|
960
|
+
"text/plain"
|
|
961
|
+
],
|
|
962
|
+
archives: ["application/zip"]
|
|
963
|
+
};
|
|
964
|
+
function expandMimeTypes(patterns) {
|
|
965
|
+
return [...new Set(patterns.flatMap((p) => MIME_GROUPS[p.toLowerCase()] ?? [p]))];
|
|
966
|
+
}
|
|
967
|
+
var startsWith = (data, bytes, offset = 0) => bytes.every((b, i) => data[offset + i] === b);
|
|
968
|
+
var ascii = (data, start, end) => String.fromCharCode(...data.subarray(start, end));
|
|
969
|
+
var contains = (data, text2) => Buffer.from(data.buffer, data.byteOffset, data.byteLength).includes(text2, 0, "latin1");
|
|
970
|
+
function zipType(data) {
|
|
971
|
+
if (ascii(data, 30, 38) === "mimetype") {
|
|
972
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
973
|
+
const start = 30 + view.getUint16(26, true) + view.getUint16(28, true);
|
|
974
|
+
const type = ascii(data, start, start + Math.min(view.getUint32(18, true), 80));
|
|
975
|
+
if (EXTENSIONS[type]?.startsWith("od")) return type;
|
|
976
|
+
}
|
|
977
|
+
if (contains(data, "word/document.xml")) return OFFICE[0];
|
|
978
|
+
if (contains(data, "xl/workbook.xml")) return OFFICE[1];
|
|
979
|
+
if (contains(data, "ppt/presentation.xml")) return OFFICE[2];
|
|
980
|
+
return "application/zip";
|
|
981
|
+
}
|
|
982
|
+
function sniffMimeType(data, name = "") {
|
|
983
|
+
if (startsWith(data, [137, 80, 78, 71, 13, 10, 26, 10])) return "image/png";
|
|
984
|
+
if (startsWith(data, [255, 216, 255])) return "image/jpeg";
|
|
985
|
+
if (ascii(data, 0, 6) === "GIF87a" || ascii(data, 0, 6) === "GIF89a") return "image/gif";
|
|
986
|
+
if (ascii(data, 0, 4) === "RIFF" && ascii(data, 8, 12) === "WEBP") return "image/webp";
|
|
987
|
+
if (ascii(data, 0, 4) === "RIFF" && ascii(data, 8, 12) === "WAVE") return "audio/wav";
|
|
988
|
+
if (ascii(data, 4, 8) === "ftyp") {
|
|
989
|
+
const brand = ascii(data, 8, 12);
|
|
990
|
+
if (brand === "avif" || brand === "avis") return "image/avif";
|
|
991
|
+
if (["isom", "iso2", "mp41", "mp42", "avc1", "M4V "].includes(brand)) return "video/mp4";
|
|
992
|
+
if (brand === "M4A ") return "audio/mp4";
|
|
993
|
+
if (brand === "qt ") return "video/quicktime";
|
|
994
|
+
}
|
|
995
|
+
if (ascii(data, 0, 5) === "%PDF-") return "application/pdf";
|
|
996
|
+
if (startsWith(data, [80, 75, 3, 4])) return zipType(data);
|
|
997
|
+
if (ascii(data, 0, 4) === "OggS") return "audio/ogg";
|
|
998
|
+
if (startsWith(data, [26, 69, 223, 163]) && ascii(data, 0, 64).includes("webm"))
|
|
999
|
+
return "video/webm";
|
|
1000
|
+
if (ascii(data, 0, 3) === "ID3" || data[0] === 255 && ((data[1] ?? 0) & 230) === 226)
|
|
1001
|
+
return "audio/mpeg";
|
|
1002
|
+
const head = data.subarray(0, 4096);
|
|
1003
|
+
if (head.includes(0)) return void 0;
|
|
1004
|
+
let text2;
|
|
1005
|
+
try {
|
|
1006
|
+
text2 = new TextDecoder("utf-8", { fatal: true }).decode(head);
|
|
1007
|
+
} catch {
|
|
1008
|
+
return void 0;
|
|
1009
|
+
}
|
|
1010
|
+
const trimmed = text2.replace(/^/, "").trimStart();
|
|
1011
|
+
if (/^(<\?xml[^>]*>\s*)?(<!--[\s\S]*?-->\s*)*(<!DOCTYPE svg[^>]*>\s*)?<svg[\s>]/i.test(trimmed))
|
|
1012
|
+
return "image/svg+xml";
|
|
1013
|
+
return /\.csv$/i.test(name) ? "text/csv" : "text/plain";
|
|
1014
|
+
}
|
|
1015
|
+
function mimeAllowed(type, allowed2) {
|
|
1016
|
+
return expandMimeTypes(allowed2).some(
|
|
1017
|
+
(pattern) => pattern.endsWith("/*") ? type.startsWith(pattern.slice(0, -1)) : pattern === type
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
function imageDimensions(data, type) {
|
|
1021
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
1022
|
+
try {
|
|
1023
|
+
switch (type) {
|
|
1024
|
+
case "image/png":
|
|
1025
|
+
return { width: view.getUint32(16), height: view.getUint32(20) };
|
|
1026
|
+
case "image/gif":
|
|
1027
|
+
return { width: view.getUint16(6, true), height: view.getUint16(8, true) };
|
|
1028
|
+
case "image/webp": {
|
|
1029
|
+
const chunk = ascii(data, 12, 16);
|
|
1030
|
+
if (chunk === "VP8 ")
|
|
1031
|
+
return {
|
|
1032
|
+
width: view.getUint16(26, true) & 16383,
|
|
1033
|
+
height: view.getUint16(28, true) & 16383
|
|
1034
|
+
};
|
|
1035
|
+
if (chunk === "VP8L") {
|
|
1036
|
+
const bits = view.getUint32(21, true);
|
|
1037
|
+
return { width: (bits & 16383) + 1, height: (bits >> 14 & 16383) + 1 };
|
|
1038
|
+
}
|
|
1039
|
+
if (chunk === "VP8X") {
|
|
1040
|
+
const w = view.getUint16(24, true) | view.getUint8(26) << 16;
|
|
1041
|
+
const h = view.getUint16(27, true) | view.getUint8(29) << 16;
|
|
1042
|
+
return { width: w + 1, height: h + 1 };
|
|
1043
|
+
}
|
|
1044
|
+
return void 0;
|
|
1045
|
+
}
|
|
1046
|
+
case "image/jpeg": {
|
|
1047
|
+
let offset = 2;
|
|
1048
|
+
while (offset + 9 < data.length) {
|
|
1049
|
+
if (data[offset] !== 255) return void 0;
|
|
1050
|
+
const marker = data[offset + 1];
|
|
1051
|
+
const length = view.getUint16(offset + 2);
|
|
1052
|
+
if (marker >= 192 && marker <= 207 && ![196, 200, 204].includes(marker)) {
|
|
1053
|
+
return { width: view.getUint16(offset + 7), height: view.getUint16(offset + 5) };
|
|
1054
|
+
}
|
|
1055
|
+
offset += 2 + length;
|
|
1056
|
+
}
|
|
1057
|
+
return void 0;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
} catch {
|
|
1061
|
+
return void 0;
|
|
1062
|
+
}
|
|
1063
|
+
return void 0;
|
|
1064
|
+
}
|
|
1065
|
+
function storageKey(originalName, type, random2) {
|
|
1066
|
+
const name = originalName.split(/[\\/]/).pop() ?? "";
|
|
1067
|
+
const base = name.replace(/(?<=.)\.[^.]*$/, "").normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{M}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "file";
|
|
1068
|
+
return `${base}-${random2}.${EXTENSIONS[type] ?? "bin"}`;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
854
1071
|
// src/fields.ts
|
|
855
1072
|
var isHasMany = (field) => (field.type === "select" || field.type === "relationship" || field.type === "upload") && field.hasMany === true;
|
|
856
1073
|
function mimeAllowedBy(mimeType, patterns) {
|
|
857
1074
|
const type = mimeType.toLowerCase();
|
|
858
|
-
return patterns.some((pattern) => {
|
|
1075
|
+
return expandMimeTypes(patterns).some((pattern) => {
|
|
859
1076
|
const p = pattern.toLowerCase();
|
|
860
1077
|
return p.endsWith("/*") ? type.startsWith(p.slice(0, -1)) : type === p;
|
|
861
1078
|
});
|
|
@@ -3070,7 +3287,7 @@ var rolesCollection = {
|
|
|
3070
3287
|
]
|
|
3071
3288
|
};
|
|
3072
3289
|
function governed(slug2) {
|
|
3073
|
-
return !INTERNAL_COLLECTIONS.has(slug2) && slug2 !== API_KEYS;
|
|
3290
|
+
return !INTERNAL_COLLECTIONS.has(slug2) && slug2 !== API_KEYS && slug2 !== MEDIA_FOLDERS;
|
|
3074
3291
|
}
|
|
3075
3292
|
function ruleFields(fields) {
|
|
3076
3293
|
return fields.filter((f) => !f.hidden && f.name !== "createdBy");
|
|
@@ -4293,6 +4510,15 @@ function validateUpload(config, add) {
|
|
|
4293
4510
|
if (upload.dir !== void 0 && (typeof upload.dir !== "string" || upload.dir.length === 0)) {
|
|
4294
4511
|
add("upload.dir", "must be a non-empty string");
|
|
4295
4512
|
}
|
|
4513
|
+
const mimeTypes = upload.mimeTypes;
|
|
4514
|
+
if (mimeTypes !== void 0 && (!Array.isArray(mimeTypes) || mimeTypes.length === 0 || !mimeTypes.every((t) => typeof t === "string" && MIME_PATTERN.test(t))))
|
|
4515
|
+
add(
|
|
4516
|
+
"upload.mimeTypes",
|
|
4517
|
+
"must be a list of MIME types or groups (documents, office, archives)",
|
|
4518
|
+
"e.g. mimeTypes: ['image/*', 'documents']"
|
|
4519
|
+
);
|
|
4520
|
+
if (upload.folders !== void 0 && typeof upload.folders !== "boolean")
|
|
4521
|
+
add("upload.folders", "must be true or false");
|
|
4296
4522
|
const fromURL = upload.fromURL;
|
|
4297
4523
|
if (fromURL === void 0) return;
|
|
4298
4524
|
if (typeof fromURL !== "object" || fromURL === null) {
|
|
@@ -4318,6 +4544,7 @@ function validateUpload(config, add) {
|
|
|
4318
4544
|
if (allowPrivate !== void 0 && typeof allowPrivate !== "boolean")
|
|
4319
4545
|
add("upload.fromURL.allowPrivate", "must be true or false");
|
|
4320
4546
|
}
|
|
4547
|
+
var MIME_PATTERN = /^([\w.+-]+\/(\*|[\w.+-]+)|documents|office|archives)$/i;
|
|
4321
4548
|
var HOST_PATTERN = /^(\*|(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*)$/i;
|
|
4322
4549
|
function validateAudit(config, add) {
|
|
4323
4550
|
const audit = config.audit;
|
|
@@ -4567,8 +4794,12 @@ function validateField(field, path, siblings, collectionSlugs, add) {
|
|
|
4567
4794
|
validateManyRange(field, path, add);
|
|
4568
4795
|
if (field.mimeTypes !== void 0) {
|
|
4569
4796
|
const types = field.mimeTypes;
|
|
4570
|
-
if (!Array.isArray(types) || types.length === 0 || !types.every((t) => typeof t === "string" &&
|
|
4571
|
-
add(
|
|
4797
|
+
if (!Array.isArray(types) || types.length === 0 || !types.every((t) => typeof t === "string" && MIME_PATTERN.test(t))) {
|
|
4798
|
+
add(
|
|
4799
|
+
`${path}.mimeTypes`,
|
|
4800
|
+
"must be a list of MIME types or groups (documents, office, archives)",
|
|
4801
|
+
"e.g. mimeTypes: ['image/*', 'documents']"
|
|
4802
|
+
);
|
|
4572
4803
|
}
|
|
4573
4804
|
}
|
|
4574
4805
|
break;
|
|
@@ -4837,6 +5068,7 @@ async function resolveConfig(input) {
|
|
|
4837
5068
|
maxFileSize: config.upload?.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
|
|
4838
5069
|
mimeTypes: config.upload?.mimeTypes ?? ["image/*", "application/pdf"],
|
|
4839
5070
|
imageSizes: config.upload?.imageSizes ?? [],
|
|
5071
|
+
folders: config.upload?.folders === true,
|
|
4840
5072
|
...config.upload?.storage ? { storage: config.upload.storage } : {},
|
|
4841
5073
|
...config.upload?.fromURL ? { fromURL: config.upload.fromURL } : {}
|
|
4842
5074
|
},
|
|
@@ -5050,99 +5282,169 @@ var silentLogger = { info() {
|
|
|
5050
5282
|
}, error() {
|
|
5051
5283
|
} };
|
|
5052
5284
|
|
|
5053
|
-
// src/media.ts
|
|
5054
|
-
var
|
|
5055
|
-
|
|
5056
|
-
|
|
5057
|
-
|
|
5058
|
-
"
|
|
5059
|
-
"
|
|
5060
|
-
|
|
5061
|
-
|
|
5062
|
-
|
|
5063
|
-
|
|
5064
|
-
|
|
5065
|
-
|
|
5066
|
-
var ascii = (data, start, end) => String.fromCharCode(...data.subarray(start, end));
|
|
5067
|
-
function sniffMimeType(data) {
|
|
5068
|
-
if (startsWith(data, [137, 80, 78, 71, 13, 10, 26, 10])) return "image/png";
|
|
5069
|
-
if (startsWith(data, [255, 216, 255])) return "image/jpeg";
|
|
5070
|
-
if (ascii(data, 0, 6) === "GIF87a" || ascii(data, 0, 6) === "GIF89a") return "image/gif";
|
|
5071
|
-
if (ascii(data, 0, 4) === "RIFF" && ascii(data, 8, 12) === "WEBP") return "image/webp";
|
|
5072
|
-
if (ascii(data, 4, 8) === "ftyp") {
|
|
5073
|
-
const brand = ascii(data, 8, 12);
|
|
5074
|
-
if (brand === "avif" || brand === "avis") return "image/avif";
|
|
5075
|
-
if (["isom", "iso2", "mp41", "mp42", "avc1", "M4V "].includes(brand)) return "video/mp4";
|
|
5285
|
+
// src/media-folders.ts
|
|
5286
|
+
var FOLDER_LEVELS = ["view", "edit", "manage"];
|
|
5287
|
+
var RANK = { none: 0, view: 1, edit: 2, manage: 3 };
|
|
5288
|
+
var TTL2 = 1e4;
|
|
5289
|
+
function needed(collection2, operation) {
|
|
5290
|
+
if (operation === "read") return "view";
|
|
5291
|
+
return collection2 === MEDIA ? "edit" : "manage";
|
|
5292
|
+
}
|
|
5293
|
+
var MediaFolders = class {
|
|
5294
|
+
constructor(config, db, roles) {
|
|
5295
|
+
this.db = db;
|
|
5296
|
+
this.roles = roles;
|
|
5297
|
+
this.enabled = config.upload.folders;
|
|
5076
5298
|
}
|
|
5077
|
-
|
|
5078
|
-
|
|
5079
|
-
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
return void 0;
|
|
5299
|
+
db;
|
|
5300
|
+
roles;
|
|
5301
|
+
enabled;
|
|
5302
|
+
cache;
|
|
5303
|
+
/** Whether folder permissions apply to this user. */
|
|
5304
|
+
applies(user) {
|
|
5305
|
+
return this.enabled && this.roles.enabled && !!user && user.role !== "admin" && !user.apiKey;
|
|
5085
5306
|
}
|
|
5086
|
-
|
|
5087
|
-
|
|
5088
|
-
|
|
5089
|
-
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
5094
|
-
|
|
5095
|
-
|
|
5096
|
-
|
|
5097
|
-
|
|
5098
|
-
|
|
5099
|
-
|
|
5100
|
-
|
|
5101
|
-
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
5105
|
-
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
|
|
5113
|
-
|
|
5114
|
-
|
|
5115
|
-
|
|
5116
|
-
|
|
5117
|
-
|
|
5118
|
-
|
|
5307
|
+
/** Forgets cached folders, after one changed. */
|
|
5308
|
+
invalidate() {
|
|
5309
|
+
this.cache = void 0;
|
|
5310
|
+
}
|
|
5311
|
+
async rows() {
|
|
5312
|
+
if (this.cache && Date.now() - this.cache.at < TTL2) return this.cache.rows;
|
|
5313
|
+
const found = await this.db.find({ collection: MEDIA_FOLDERS, sort: [], limit: 0, page: 1 });
|
|
5314
|
+
const rows = /* @__PURE__ */ new Map();
|
|
5315
|
+
for (const doc of found.docs) {
|
|
5316
|
+
const permissions = doc.permissions;
|
|
5317
|
+
rows.set(String(doc.id), {
|
|
5318
|
+
id: doc.id,
|
|
5319
|
+
parent: doc.parent ?? null,
|
|
5320
|
+
name: String(doc.name ?? ""),
|
|
5321
|
+
permissions: permissions && typeof permissions === "object" && !Array.isArray(permissions) ? permissions : null
|
|
5322
|
+
});
|
|
5323
|
+
}
|
|
5324
|
+
this.cache = { at: Date.now(), rows };
|
|
5325
|
+
return rows;
|
|
5326
|
+
}
|
|
5327
|
+
/** A role's level in a folder (`null`: the top level): the nearest permissions up the tree. */
|
|
5328
|
+
levelIn(rows, role, id) {
|
|
5329
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5330
|
+
let current = id === null ? void 0 : rows.get(String(id));
|
|
5331
|
+
while (current && !seen.has(String(current.id))) {
|
|
5332
|
+
seen.add(String(current.id));
|
|
5333
|
+
if (current.permissions) {
|
|
5334
|
+
const level = current.permissions[role];
|
|
5335
|
+
return level && level in RANK ? level : "none";
|
|
5336
|
+
}
|
|
5337
|
+
current = current.parent === null ? void 0 : rows.get(String(current.parent));
|
|
5338
|
+
}
|
|
5339
|
+
return "manage";
|
|
5340
|
+
}
|
|
5341
|
+
/** The user's level in a folder (`null`: the top level). */
|
|
5342
|
+
async level(user, id) {
|
|
5343
|
+
if (!this.applies(user)) return "manage";
|
|
5344
|
+
return this.levelIn(await this.rows(), user.role, id);
|
|
5345
|
+
}
|
|
5346
|
+
/**
|
|
5347
|
+
* The constraint folder permissions add to an operation on files or folders, or `true` when
|
|
5348
|
+
* they add none (no folder limits this user).
|
|
5349
|
+
*/
|
|
5350
|
+
async where(user, collection2, operation) {
|
|
5351
|
+
if (collection2 !== MEDIA && collection2 !== MEDIA_FOLDERS || !this.applies(user)) return true;
|
|
5352
|
+
if (operation === "create") return true;
|
|
5353
|
+
const need = RANK[needed(collection2, operation)];
|
|
5354
|
+
const rows = await this.rows();
|
|
5355
|
+
const allowed2 = [];
|
|
5356
|
+
let all = true;
|
|
5357
|
+
for (const row of rows.values()) {
|
|
5358
|
+
if (RANK[this.levelIn(rows, user.role, row.id)] >= need) allowed2.push(row.id);
|
|
5359
|
+
else all = false;
|
|
5360
|
+
}
|
|
5361
|
+
if (all) return true;
|
|
5362
|
+
if (collection2 === MEDIA_FOLDERS) return { id: { in: allowed2 } };
|
|
5363
|
+
const outside = { folder: { exists: false } };
|
|
5364
|
+
return allowed2.length === 0 ? outside : { or: [{ folder: { in: allowed2 } }, outside] };
|
|
5365
|
+
}
|
|
5366
|
+
/** Refuses putting a file (`media`) or a subfolder in a folder the user may not. */
|
|
5367
|
+
async checkTarget(user, collection2, folder) {
|
|
5368
|
+
if (!this.applies(user)) return;
|
|
5369
|
+
const need = collection2 === MEDIA ? "edit" : "manage";
|
|
5370
|
+
if (RANK[await this.level(user, folder)] < RANK[need])
|
|
5371
|
+
throw new ForbiddenError("You may not add to this folder");
|
|
5372
|
+
}
|
|
5373
|
+
/**
|
|
5374
|
+
* Checks a folder before it is saved: a name unique among its siblings (ignoring case), a
|
|
5375
|
+
* parent that is not itself or inside it, and permissions for roles that exist.
|
|
5376
|
+
*/
|
|
5377
|
+
async validate(data, self) {
|
|
5378
|
+
const rows = await this.fresh();
|
|
5379
|
+
const name = typeof data.name === "string" ? data.name.trim() : "";
|
|
5380
|
+
const parent = data.parent ?? null;
|
|
5381
|
+
const errors = [];
|
|
5382
|
+
if (parent !== null && self !== void 0) {
|
|
5383
|
+
let current = rows.get(String(parent));
|
|
5384
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5385
|
+
while (current && !seen.has(String(current.id))) {
|
|
5386
|
+
if (String(current.id) === String(self)) {
|
|
5387
|
+
errors.push({ field: "parent", message: "cannot be the folder itself or inside it" });
|
|
5388
|
+
break;
|
|
5119
5389
|
}
|
|
5120
|
-
|
|
5390
|
+
seen.add(String(current.id));
|
|
5391
|
+
current = current.parent === null ? void 0 : rows.get(String(current.parent));
|
|
5121
5392
|
}
|
|
5122
|
-
|
|
5123
|
-
|
|
5124
|
-
|
|
5125
|
-
|
|
5126
|
-
|
|
5127
|
-
|
|
5128
|
-
|
|
5129
|
-
|
|
5130
|
-
|
|
5131
|
-
|
|
5393
|
+
}
|
|
5394
|
+
const taken = [...rows.values()].some(
|
|
5395
|
+
(row) => String(row.id) !== String(self) && String(row.parent) === String(parent) && row.name.trim().toLocaleLowerCase() === name.toLocaleLowerCase()
|
|
5396
|
+
);
|
|
5397
|
+
if (name && taken) errors.push({ field: "name", message: "is already used in this folder" });
|
|
5398
|
+
const permissions = data.permissions;
|
|
5399
|
+
if (permissions !== null && permissions !== void 0) {
|
|
5400
|
+
const roles = new Set((await this.roles.options()).map((r) => r.key));
|
|
5401
|
+
if (typeof permissions !== "object" || Array.isArray(permissions))
|
|
5402
|
+
errors.push({ field: "permissions", message: "must be null or roles with their levels" });
|
|
5403
|
+
else
|
|
5404
|
+
for (const [role, level] of Object.entries(permissions)) {
|
|
5405
|
+
if (!roles.has(role))
|
|
5406
|
+
errors.push({ field: "permissions", message: `"${role}" is not a role` });
|
|
5407
|
+
else if (!FOLDER_LEVELS.includes(level))
|
|
5408
|
+
errors.push({
|
|
5409
|
+
field: "permissions",
|
|
5410
|
+
message: `"${role}" must be one of ${FOLDER_LEVELS.join(", ")}`
|
|
5411
|
+
});
|
|
5132
5412
|
}
|
|
5133
|
-
|
|
5413
|
+
}
|
|
5414
|
+
if (errors.length > 0) throw new ValidationError(MEDIA_FOLDERS, errors);
|
|
5415
|
+
}
|
|
5416
|
+
/**
|
|
5417
|
+
* Before a folder is deleted: its files and subfolders move up to its parent. Returns how many
|
|
5418
|
+
* moved.
|
|
5419
|
+
*/
|
|
5420
|
+
async release(folder) {
|
|
5421
|
+
const parent = folder.parent ?? null;
|
|
5422
|
+
const moved = { files: 0, folders: 0 };
|
|
5423
|
+
for (const [collection2, field, key2] of [
|
|
5424
|
+
[MEDIA, "folder", "files"],
|
|
5425
|
+
[MEDIA_FOLDERS, "parent", "folders"]
|
|
5426
|
+
]) {
|
|
5427
|
+
const found = await this.db.find({
|
|
5428
|
+
collection: collection2,
|
|
5429
|
+
where: { [field]: { equals: folder.id } },
|
|
5430
|
+
sort: [],
|
|
5431
|
+
limit: 0,
|
|
5432
|
+
page: 1
|
|
5433
|
+
});
|
|
5434
|
+
for (const doc of found.docs) {
|
|
5435
|
+
const { id, ...rest } = doc;
|
|
5436
|
+
await this.db.update({ collection: collection2, id, data: { ...rest, [field]: parent } });
|
|
5437
|
+
moved[key2]++;
|
|
5134
5438
|
}
|
|
5135
5439
|
}
|
|
5136
|
-
|
|
5137
|
-
return
|
|
5440
|
+
this.invalidate();
|
|
5441
|
+
return moved;
|
|
5138
5442
|
}
|
|
5139
|
-
|
|
5140
|
-
|
|
5141
|
-
|
|
5142
|
-
|
|
5143
|
-
|
|
5144
|
-
return `${base}-${random2}.${EXTENSIONS[type] ?? "bin"}`;
|
|
5145
|
-
}
|
|
5443
|
+
async fresh() {
|
|
5444
|
+
this.invalidate();
|
|
5445
|
+
return this.rows();
|
|
5446
|
+
}
|
|
5447
|
+
};
|
|
5146
5448
|
|
|
5147
5449
|
// src/populate.ts
|
|
5148
5450
|
var DEFAULT_DEPTH = 1;
|
|
@@ -5461,6 +5763,8 @@ var EasyCMS = class {
|
|
|
5461
5763
|
roles;
|
|
5462
5764
|
/** Who did what (`audit`, Settings → Audit log). */
|
|
5463
5765
|
audit;
|
|
5766
|
+
/** Folders of the media library and their permissions (`upload.folders`). */
|
|
5767
|
+
folders;
|
|
5464
5768
|
versions;
|
|
5465
5769
|
webhooks;
|
|
5466
5770
|
mailer;
|
|
@@ -5478,6 +5782,7 @@ var EasyCMS = class {
|
|
|
5478
5782
|
(entry) => this.audit.record(entry)
|
|
5479
5783
|
);
|
|
5480
5784
|
this.audit = new AuditLog(this);
|
|
5785
|
+
this.folders = new MediaFolders(config, db, this.roles);
|
|
5481
5786
|
this.webhooks = new Webhooks(
|
|
5482
5787
|
config.webhooks ?? [],
|
|
5483
5788
|
logger,
|
|
@@ -5572,6 +5877,7 @@ var EasyCMS = class {
|
|
|
5572
5877
|
await this.checkGrant(guard, { collection: MEDIA }, "create");
|
|
5573
5878
|
const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data });
|
|
5574
5879
|
if (allowed2 !== true) throw deny(guard.user);
|
|
5880
|
+
await this.folders.checkTarget(guard.user, MEDIA, folderOf(MEDIA, data));
|
|
5575
5881
|
}
|
|
5576
5882
|
const { maxFileSize, mimeTypes, imageSizes } = this.config.upload;
|
|
5577
5883
|
if (file.data.byteLength > maxFileSize) {
|
|
@@ -5579,7 +5885,7 @@ var EasyCMS = class {
|
|
|
5579
5885
|
}
|
|
5580
5886
|
if (file.data.byteLength === 0)
|
|
5581
5887
|
throw new ValidationError(MEDIA, [{ field: "file", message: "is empty" }]);
|
|
5582
|
-
const mimeType = sniffMimeType(file.data);
|
|
5888
|
+
const mimeType = sniffMimeType(file.data, file.name);
|
|
5583
5889
|
if (!mimeType || !mimeAllowed(mimeType, mimeTypes)) {
|
|
5584
5890
|
throw new ValidationError(MEDIA, [
|
|
5585
5891
|
{
|
|
@@ -5903,6 +6209,11 @@ var EasyCMS = class {
|
|
|
5903
6209
|
const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
|
|
5904
6210
|
if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
|
|
5905
6211
|
await this.checkDocumentAccess(config, "update", guard, parsed, raw);
|
|
6212
|
+
if (guard.enforce && inFolders(this.config, collection2)) {
|
|
6213
|
+
const key2 = collection2 === MEDIA ? "folder" : "parent";
|
|
6214
|
+
if (Object.hasOwn(raw, key2) && String(raw[key2] ?? null) !== String(existing[key2] ?? null))
|
|
6215
|
+
await this.folders.checkTarget(guard.user, collection2, folderOf(collection2, raw));
|
|
6216
|
+
}
|
|
5906
6217
|
if (config.drafts && (mode === "unpublish" || raw.status === "published"))
|
|
5907
6218
|
await this.checkDocumentGrant(guard, collection2, "publish", parsed);
|
|
5908
6219
|
const [drafted] = await this.withDrafts(config, [existing]);
|
|
@@ -5968,11 +6279,13 @@ var EasyCMS = class {
|
|
|
5968
6279
|
}
|
|
5969
6280
|
if (config.slug === USERS) await this.guardLastAdmin(parsed, existing, prepared);
|
|
5970
6281
|
if (password !== void 0) prepared.passwordHash = await hashPassword(password);
|
|
6282
|
+
if (collection2 === MEDIA_FOLDERS) await this.folders.validate(prepared, parsed);
|
|
5971
6283
|
const doc = await this.db.update({
|
|
5972
6284
|
collection: collection2,
|
|
5973
6285
|
id: parsed,
|
|
5974
6286
|
data: { ...prepared, createdAt: existing.createdAt, updatedAt: now }
|
|
5975
6287
|
});
|
|
6288
|
+
if (collection2 === MEDIA_FOLDERS) this.folders.invalidate();
|
|
5976
6289
|
if (!live) await this.saveVersion(config, collectionParent(collection2), parsed, doc, guard);
|
|
5977
6290
|
if (password !== void 0) await this.auth.revokeSessions(parsed);
|
|
5978
6291
|
await this.notify(config.hooks?.afterChange, "afterChange", config.slug, {
|
|
@@ -6015,7 +6328,9 @@ var EasyCMS = class {
|
|
|
6015
6328
|
await this.auth.revokeSessions(parsed);
|
|
6016
6329
|
await this.auth.sso.forget(parsed);
|
|
6017
6330
|
}
|
|
6331
|
+
if (collection2 === MEDIA_FOLDERS) await this.folders.release(existing);
|
|
6018
6332
|
await this.db.delete({ collection: collection2, id: parsed });
|
|
6333
|
+
if (collection2 === MEDIA_FOLDERS) this.folders.invalidate();
|
|
6019
6334
|
if (versionLimit(config)) await this.versions.deleteAll(collectionParent(collection2), parsed);
|
|
6020
6335
|
if (config.schedule) {
|
|
6021
6336
|
for (const job of await this.pendingJobs(collection2, parsed))
|
|
@@ -6585,11 +6900,16 @@ var EasyCMS = class {
|
|
|
6585
6900
|
async checkGrant(guard, target, operation) {
|
|
6586
6901
|
if (!guard.enforce) return true;
|
|
6587
6902
|
const name = "collection" in target ? target.collection : target.global;
|
|
6588
|
-
|
|
6903
|
+
const folders = "collection" in target && target.collection === MEDIA_FOLDERS;
|
|
6904
|
+
const granted = folders ? { collection: MEDIA } : target;
|
|
6905
|
+
if (!keyAllows(guard.user, granted, operation))
|
|
6589
6906
|
throw new ForbiddenError(`This API key may not ${operation} "${name}"`);
|
|
6590
|
-
|
|
6907
|
+
let role = await this.roles.allows(guard.user, granted, operation);
|
|
6591
6908
|
if (role === false) throw deny(guard.user);
|
|
6592
|
-
|
|
6909
|
+
if (folders && role !== true && guard.user) role = { createdBy: { equals: guard.user.id } };
|
|
6910
|
+
const inFolder = await this.folders.where(guard.user, name, operation);
|
|
6911
|
+
if (inFolder === true) return role;
|
|
6912
|
+
return role === true ? inFolder : { and: [role, inFolder] };
|
|
6593
6913
|
}
|
|
6594
6914
|
/** The query constraint read access adds, or `undefined` when there is none. Throws when denied. */
|
|
6595
6915
|
async readWhere(config, guard, where) {
|
|
@@ -6717,6 +7037,8 @@ var EasyCMS = class {
|
|
|
6717
7037
|
if (typeof allowed2 === "object")
|
|
6718
7038
|
throw new QueryError(`create access of "${collection2}" must return a boolean`);
|
|
6719
7039
|
if (!allowed2) throw deny(guard.user);
|
|
7040
|
+
if (inFolders(this.config, collection2))
|
|
7041
|
+
await this.folders.checkTarget(guard.user, collection2, folderOf(collection2, raw));
|
|
6720
7042
|
}
|
|
6721
7043
|
const { input, password } = splitPassword(config, raw);
|
|
6722
7044
|
const filtered = await filterInput(
|
|
@@ -6752,11 +7074,13 @@ var EasyCMS = class {
|
|
|
6752
7074
|
prepared
|
|
6753
7075
|
);
|
|
6754
7076
|
if (password !== void 0) prepared.passwordHash = await hashPassword(password);
|
|
7077
|
+
if (collection2 === MEDIA_FOLDERS) await this.folders.validate(prepared, void 0);
|
|
6755
7078
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
6756
7079
|
const doc = await this.db.create({
|
|
6757
7080
|
collection: collection2,
|
|
6758
7081
|
data: { ...prepared, createdAt: now, updatedAt: now }
|
|
6759
7082
|
});
|
|
7083
|
+
if (collection2 === MEDIA_FOLDERS) this.folders.invalidate();
|
|
6760
7084
|
await this.saveVersion(config, collectionParent(collection2), doc.id, doc, guard);
|
|
6761
7085
|
await this.notify(config.hooks?.afterChange, "afterChange", collection2, {
|
|
6762
7086
|
...base,
|
|
@@ -6845,7 +7169,9 @@ var EasyCMS = class {
|
|
|
6845
7169
|
async audited(config, event, doc, previous, guard, mode = "save") {
|
|
6846
7170
|
if (!this.audit.enabled) return;
|
|
6847
7171
|
let action = event;
|
|
6848
|
-
if (event === "update"
|
|
7172
|
+
if (config.slug === MEDIA_FOLDERS && event === "update" && JSON.stringify(doc.permissions ?? null) !== JSON.stringify(previous?.permissions ?? null))
|
|
7173
|
+
action = "folder.permissions";
|
|
7174
|
+
else if (event === "update" || event === "draft") {
|
|
6849
7175
|
const was = previous?.status;
|
|
6850
7176
|
if (mode === "restore") action = "restore";
|
|
6851
7177
|
else if (config.drafts && doc.status === "published" && was !== "published")
|
|
@@ -7154,6 +7480,13 @@ function asObject(data, name) {
|
|
|
7154
7480
|
}
|
|
7155
7481
|
return data;
|
|
7156
7482
|
}
|
|
7483
|
+
function inFolders(config, collection2) {
|
|
7484
|
+
return config.upload.folders && (collection2 === MEDIA || collection2 === MEDIA_FOLDERS);
|
|
7485
|
+
}
|
|
7486
|
+
function folderOf(collection2, data) {
|
|
7487
|
+
const value = data[collection2 === MEDIA ? "folder" : "parent"];
|
|
7488
|
+
return value === void 0 || value === "" ? null : value;
|
|
7489
|
+
}
|
|
7157
7490
|
|
|
7158
7491
|
// src/rest/admin-modules.ts
|
|
7159
7492
|
import { createHash as createHash3 } from "crypto";
|
|
@@ -7454,7 +7787,7 @@ async function serializeFields(fields, checks, localized) {
|
|
|
7454
7787
|
if (field.hasMany) f.hasMany = true;
|
|
7455
7788
|
if (field.minRows !== void 0) f.minRows = field.minRows;
|
|
7456
7789
|
if (field.maxRows !== void 0) f.maxRows = field.maxRows;
|
|
7457
|
-
if (field.mimeTypes) f.mimeTypes =
|
|
7790
|
+
if (field.mimeTypes) f.mimeTypes = expandMimeTypes(field.mimeTypes);
|
|
7458
7791
|
break;
|
|
7459
7792
|
case "array":
|
|
7460
7793
|
if (field.minRows !== void 0) f.minRows = field.minRows;
|
|
@@ -7578,6 +7911,11 @@ async function adminSchema(cms, user, origin) {
|
|
|
7578
7911
|
globals: await Promise.all(cms.config.globals.map((g) => global(cms, g, user, localized))),
|
|
7579
7912
|
modules: adminModuleUrls(cms),
|
|
7580
7913
|
uploadFromURL: cms.config.upload.fromURL !== void 0,
|
|
7914
|
+
upload: {
|
|
7915
|
+
maxFileSize: cms.config.upload.maxFileSize,
|
|
7916
|
+
mimeTypes: expandMimeTypes(cms.config.upload.mimeTypes)
|
|
7917
|
+
},
|
|
7918
|
+
...cms.folders.enabled ? { folders: { permissions: admin && cms.roles.enabled } } : {},
|
|
7581
7919
|
views,
|
|
7582
7920
|
rbac: cms.roles.enabled,
|
|
7583
7921
|
providers: cms.auth.sso.providers(),
|
|
@@ -7626,7 +7964,7 @@ async function widgets(cms, user) {
|
|
|
7626
7964
|
}
|
|
7627
7965
|
|
|
7628
7966
|
// src/version.ts
|
|
7629
|
-
var VERSION = true ? "0.
|
|
7967
|
+
var VERSION = true ? "0.39.0" : "0.0.0-dev";
|
|
7630
7968
|
|
|
7631
7969
|
// src/rest/admin-status.ts
|
|
7632
7970
|
var DAY2 = 864e5;
|
|
@@ -8621,16 +8959,20 @@ async function serveFile(cms, key2, head) {
|
|
|
8621
8959
|
const file = await cms.storage.get(key2);
|
|
8622
8960
|
if (!file) throw new HttpError("Not found", 404);
|
|
8623
8961
|
const extension = key2.slice(key2.lastIndexOf(".") + 1);
|
|
8962
|
+
const type = TYPE_BY_EXTENSION[extension] ?? "application/octet-stream";
|
|
8624
8963
|
return new Response(
|
|
8625
8964
|
head ? null : file.body,
|
|
8626
8965
|
{
|
|
8627
8966
|
status: 200,
|
|
8628
8967
|
headers: {
|
|
8629
|
-
"content-type":
|
|
8968
|
+
"content-type": type,
|
|
8630
8969
|
"content-length": String(file.size),
|
|
8631
8970
|
"cache-control": "public, max-age=31536000, immutable",
|
|
8632
8971
|
"x-content-type-options": "nosniff",
|
|
8633
|
-
|
|
8972
|
+
// Browsers won't show a PDF in a sandbox; their PDF viewers run apart from the site.
|
|
8973
|
+
...type === "application/pdf" ? {} : {
|
|
8974
|
+
"content-security-policy": "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox"
|
|
8975
|
+
},
|
|
8634
8976
|
"cross-origin-resource-policy": "cross-origin"
|
|
8635
8977
|
}
|
|
8636
8978
|
}
|
|
@@ -8974,12 +9316,16 @@ export {
|
|
|
8974
9316
|
EasyCMSError,
|
|
8975
9317
|
FIELD_TYPES,
|
|
8976
9318
|
FIELD_TYPE_BASES,
|
|
9319
|
+
FOLDER_LEVELS,
|
|
8977
9320
|
ForbiddenError,
|
|
8978
9321
|
INTERNAL_COLLECTIONS,
|
|
8979
9322
|
MAX_DEPTH,
|
|
8980
9323
|
MEDIA,
|
|
9324
|
+
MEDIA_FOLDERS,
|
|
9325
|
+
MIME_GROUPS,
|
|
8981
9326
|
MIN_PASSWORD_LENGTH,
|
|
8982
9327
|
MIN_SECRET_LENGTH,
|
|
9328
|
+
MediaFolders,
|
|
8983
9329
|
NotFoundError,
|
|
8984
9330
|
PayloadTooLargeError,
|
|
8985
9331
|
QueryError,
|
|
@@ -9006,6 +9352,7 @@ export {
|
|
|
9006
9352
|
defineConfig,
|
|
9007
9353
|
defineFieldType,
|
|
9008
9354
|
definePlugin,
|
|
9355
|
+
expandMimeTypes,
|
|
9009
9356
|
fillMissing,
|
|
9010
9357
|
findConfigFile,
|
|
9011
9358
|
generateSlugs,
|