@easy-cms/core 0.40.0 → 0.41.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
@@ -1047,6 +1047,17 @@ declare class MediaFolders {
1047
1047
  id: ID;
1048
1048
  private: boolean;
1049
1049
  }[]>;
1050
+ /**
1051
+ * The privacy each folder of a subtree would have after a change to its top folder: made
1052
+ * private or public, moved, or (as `private: false`) deleted with its contents moving up.
1053
+ */
1054
+ privacyAfter(id: ID, change: {
1055
+ private?: boolean;
1056
+ parent?: ID | null;
1057
+ }): Promise<{
1058
+ id: ID;
1059
+ private: boolean;
1060
+ }[]>;
1050
1061
  /**
1051
1062
  * The folder with this key (`folder: 'banners'` on upload fields), made at the top level when
1052
1063
  * there is none yet.
@@ -1103,6 +1114,28 @@ interface StorageAdapter {
1103
1114
  * (`<api>/media/file/<key>`), as the local adapter does.
1104
1115
  */
1105
1116
  url?(key: string): string | undefined;
1117
+ /**
1118
+ * Lets a browser send a file straight to the storage (large files, past a host's request
1119
+ * limit): where to send it and how. The URL must work for `expiresIn` seconds and accept at
1120
+ * most `size` bytes. Without it, files are uploaded through the server.
1121
+ */
1122
+ uploadURL?(key: string, options: {
1123
+ contentType: string;
1124
+ size: number;
1125
+ expiresIn: number;
1126
+ }): Promise<DirectUpload>;
1127
+ /**
1128
+ * The first `bytes` of a file and its whole size, without reading it all (to check a file
1129
+ * uploaded straight to the storage); `null` when there is no such file. Default: `get`.
1130
+ */
1131
+ getStart?(key: string, bytes: number): Promise<StoredFile | null>;
1132
+ }
1133
+ /** Where and how a browser sends a file straight to the storage (`StorageAdapter.uploadURL`). */
1134
+ interface DirectUpload {
1135
+ readonly url: string;
1136
+ readonly method: 'PUT' | 'POST';
1137
+ /** Headers to send with the file. */
1138
+ readonly headers: Readonly<Record<string, string>>;
1106
1139
  }
1107
1140
  interface LocalStorageOptions {
1108
1141
  /** Directory, relative to the project root. Default `uploads`. */
@@ -1254,6 +1287,32 @@ declare class EasyCMS<C extends Config = Config> {
1254
1287
  data: Uint8Array;
1255
1288
  name: string;
1256
1289
  }, data?: Record<string, unknown>, options?: DepthOptions): Promise<MediaDocument>;
1290
+ /**
1291
+ * Starts an upload straight to the storage, for files too large to send through the server
1292
+ * (`POST <api>/media/uploads`): checks who uploads what where, as `upload()` does, and returns
1293
+ * a signed ticket and where to send the file. `upload` is `null` when the storage can't take
1294
+ * files directly: send it to `upload()` instead. Then `completeUpload(ticket)`.
1295
+ */
1296
+ createUpload(file: {
1297
+ name: string;
1298
+ size: number;
1299
+ type?: string;
1300
+ }, data?: Record<string, unknown>, options?: AccessOptions): Promise<{
1301
+ ticket: string;
1302
+ upload: DirectUpload | null;
1303
+ }>;
1304
+ /**
1305
+ * Finishes an upload made with `createUpload()` (`POST <api>/media/uploads/complete`): checks
1306
+ * the file in the storage like any upload (its size, and its type from its contents), then
1307
+ * makes its media document. A file that fails is deleted.
1308
+ */
1309
+ completeUpload(ticket: string, options?: DepthOptions): Promise<MediaDocument>;
1310
+ /** Who may upload, and into which folder: `upload()` and direct uploads. */
1311
+ private checkUpload;
1312
+ /** A value with its HMAC (`secret`), for `purpose` only: `<base64url JSON>.<signature>`. */
1313
+ private sign;
1314
+ /** The value of a token made by `sign`, or `undefined` when it isn't genuine. */
1315
+ private verify;
1257
1316
  /**
1258
1317
  * Downloads a file from an `http(s)` link and uploads it like `upload()`. Private network
1259
1318
  * addresses are refused unless `upload.fromURL.allowPrivate`. Calls on behalf of a user (the
@@ -1307,6 +1366,11 @@ declare class EasyCMS<C extends Config = Config> {
1307
1366
  private relocate;
1308
1367
  /** A folder's name, parent and permissions; private only with somewhere private to keep files. */
1309
1368
  private validateFolder;
1369
+ /**
1370
+ * Refuses a change that would move more than 200 files between storages in one request: they
1371
+ * are copied one by one, which could outlast a serverless function.
1372
+ */
1373
+ private checkMoveLimit;
1310
1374
  /** After a folder became private or public (or moved): its files follow, in every subfolder. */
1311
1375
  private settlePrivacy;
1312
1376
  /** Files at the top level are public. */
@@ -3231,4 +3295,4 @@ declare function validateConfig(config: Config): ConfigIssue[];
3231
3295
  /** The version of `@easy-cms/core`, set when the package is built. */
3232
3296
  declare const VERSION: string;
3233
3297
 
3234
- export { ADMIN_ICONS, API_KEYS, API_KEY_GLOBAL_OPERATIONS, API_KEY_OPERATIONS, type Access, type AccessArgs, type AccessOptions, type AdminAttention, type AdminBackup, type AdminBackups, type AdminBrand, type AdminCollection, type AdminComponent, type AdminComponentRef, type AdminConfig, type AdminDeliveries, type AdminDelivery, type AdminEmail, type AdminEmailDelivery, type AdminField, type AdminGlobal, type AdminIcon, type AdminLocale, type AdminPage, type AdminPageRef, type AdminRole, type AdminRoleChange, type AdminRoleField, type AdminRoleTarget, type AdminRoleView, type AdminRoles, type AdminSchema, type AdminSso, type AdminStatus, type AdminViewAccess, type AdminWebhookDelivery, type AdminWidgetRef, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ApiKeyContext, type ApiKeyGlobalOperation, type ApiKeyOperation, type ApiKeyPermissions, type ArrayField, type AuditChange, type AuditConfig, type AuditEntry, AuditLog, type AuditPage, type AuditVerification, Auth, type AuthConfig, type AuthProvider, type AuthProviderProfile, type AuthProviderRequest, type AuthUser, BUILTIN_COLLECTIONS, type BackupState, type BackupsConfig, type BeforeChangeHook, type BeforeDeleteHook, type BeforeValidateHook, type Block, type BlocksField, type BooleanField, type BuiltinApiKeysCollection, type BuiltinMediaCollection, type BuiltinMediaFoldersCollection, type BuiltinUsersCollection, type BundleHints, CONFIG_FILE_NAMES, CSRF_COOKIE, CSRF_HEADER, type CliCommand, type CollectionAccess, type CollectionAdmin, type CollectionConfig, type CollectionDocument, type CollectionHooks, type CollectionListAdmin, type CollectionSlug, type Config, ConfigError, type ConfigIssue, type ContainerAdmin, type CopyProgress, type CopyResult, type CreateEasyCMSOptions, type CreateInput, type CreateMigrationResult, type CustomField, type CustomFieldTypes, DEFAULT_ADMIN_PATH, DEFAULT_API_PATH, DEFAULT_DEPTH, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_VERSIONS, DEFAULT_PASSWORD_EMAILS, DEFAULT_TOKEN_EXPIRATION, type DashboardWidget, type Database, type DatabaseAdapter, type DatabaseInitArgs, type DatabaseTransfer, type DateField, type DeliveryKind, type DeliveryState, type DepthOptions, type DocumentOf, EXTENSIONS, EasyCMS, EasyCMSError, type EmailAdapter, type EmailCheck, type EmailField, type EmailMessage, type EmailSetting, type Endpoint, type EndpointRequest, FIELD_TYPES, FIELD_TYPE_BASES, FOLDER_LEVELS, type Field, type FieldAccess, type FieldAccessFn, type FieldAdmin, type FieldError, type FieldRule, type FieldType, type FieldTypeBase, type FieldTypeDefinition, type FieldTypeValidateContext, type FieldValidate, type FieldValidateContext, type FieldValue, type FieldsInput, type FieldsValue, type FilterOptions, type FilterOptionsArgs, type FindArgs, type FindOptions, type FolderLevel, type FolderPermissions, ForbiddenError, type GlobalAccess, type GlobalConfig, type GlobalDocument, type GlobalHooks, type GlobalInput, type GlobalSlug, type GroupField, type ID, INTERNAL_COLLECTIONS, type ImageSize, type InferCollection, type InferGlobal, type JsonField, type Label, type LivePreview, type LoadConfigOptions, type LocalStorageOptions, type LocalizationConfig, type Logger, type LoginArgs, MAX_DEPTH, MEDIA, MEDIA_FOLDERS, MIME_GROUPS, MIN_PASSWORD_LENGTH, MIN_SECRET_LENGTH, type MediaDocument, MediaFolders, type MediaSize, type MigrationInfo, NotFoundError, type NumberField, type Operation, type PaginatedDocs, type PasswordEmail, type PasswordEmailArgs, type PasswordEmailFn, type PasswordLinkOptions, PayloadTooLargeError, type Plugin, type PluginInfo, type PluginTypes, type PreviewTarget, type PreviewURL, QueryError, ROLE_OPERATIONS, type RawDocument, type ReadOptions, type RelationshipField, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoleOperation, type RolePermissions, Roles, type RoutesConfig, SESSION_COOKIE, SSO_COOKIE, type ScheduledJob, SchemaError, type SchemaMode, type SelectField, type SelectOption, type Session, type SlugField, type SlugOf, type SsoOutcome, type SsoProviderRef, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, type TypedPlugin, UnauthorizedError, type UpdateInput, type UpdateOptions, type UploadConfig, type UploadField, type UploadFromURLConfig, type UserIdentity, VERSION, ValidationError, type Version, type VersionSummary, type VersionsConfig, WEBHOOK_EVENTS, type WebhookConfig, type WebhookEvent, type WebhookPayload, type Where, type WhereOperators, anyone, applyDefaults, applyPlugins, configSignature, consoleEmail, consoleLogger, copyDatabase, createEasyCMS, createRestHandler, createRootEndpointHandler, defineConfig, defineFieldType, definePlugin, expandMimeTypes, fillMissing, findConfigFile, generateSlugs, generateTypes, hasRows, hashPassword, imageDimensions, importConfig, isAdmin, isHasMany, isLoggedIn, keyAllows, loadConfig, localStorage, mergeForUpdate, mimeAllowed, mimeAllowedBy, parseId, populate, readCookie, resolveAdminModule, resolveConfig, rowFields, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword, writeBackupFile };
3298
+ export { ADMIN_ICONS, API_KEYS, API_KEY_GLOBAL_OPERATIONS, API_KEY_OPERATIONS, type Access, type AccessArgs, type AccessOptions, type AdminAttention, type AdminBackup, type AdminBackups, type AdminBrand, type AdminCollection, type AdminComponent, type AdminComponentRef, type AdminConfig, type AdminDeliveries, type AdminDelivery, type AdminEmail, type AdminEmailDelivery, type AdminField, type AdminGlobal, type AdminIcon, type AdminLocale, type AdminPage, type AdminPageRef, type AdminRole, type AdminRoleChange, type AdminRoleField, type AdminRoleTarget, type AdminRoleView, type AdminRoles, type AdminSchema, type AdminSso, type AdminStatus, type AdminViewAccess, type AdminWebhookDelivery, type AdminWidgetRef, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ApiKeyContext, type ApiKeyGlobalOperation, type ApiKeyOperation, type ApiKeyPermissions, type ArrayField, type AuditChange, type AuditConfig, type AuditEntry, AuditLog, type AuditPage, type AuditVerification, Auth, type AuthConfig, type AuthProvider, type AuthProviderProfile, type AuthProviderRequest, type AuthUser, BUILTIN_COLLECTIONS, type BackupState, type BackupsConfig, type BeforeChangeHook, type BeforeDeleteHook, type BeforeValidateHook, type Block, type BlocksField, type BooleanField, type BuiltinApiKeysCollection, type BuiltinMediaCollection, type BuiltinMediaFoldersCollection, type BuiltinUsersCollection, type BundleHints, CONFIG_FILE_NAMES, CSRF_COOKIE, CSRF_HEADER, type CliCommand, type CollectionAccess, type CollectionAdmin, type CollectionConfig, type CollectionDocument, type CollectionHooks, type CollectionListAdmin, type CollectionSlug, type Config, ConfigError, type ConfigIssue, type ContainerAdmin, type CopyProgress, type CopyResult, type CreateEasyCMSOptions, type CreateInput, type CreateMigrationResult, type CustomField, type CustomFieldTypes, DEFAULT_ADMIN_PATH, DEFAULT_API_PATH, DEFAULT_DEPTH, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_VERSIONS, DEFAULT_PASSWORD_EMAILS, DEFAULT_TOKEN_EXPIRATION, type DashboardWidget, type Database, type DatabaseAdapter, type DatabaseInitArgs, type DatabaseTransfer, type DateField, type DeliveryKind, type DeliveryState, type DepthOptions, type DirectUpload, 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
@@ -1095,6 +1095,12 @@ function imageDimensions(data, type) {
1095
1095
  }
1096
1096
  return void 0;
1097
1097
  }
1098
+ function typeFromName(name) {
1099
+ const extension = /\.([a-z0-9]+)$/i.exec(name)?.[1]?.toLowerCase();
1100
+ if (!extension) return void 0;
1101
+ if (extension === "jpeg") return "image/jpeg";
1102
+ return Object.entries(EXTENSIONS).find(([, ext]) => ext === extension)?.[0];
1103
+ }
1098
1104
  function storageKey(originalName, type, random2) {
1099
1105
  const name = originalName.split(/[\\/]/).pop() ?? "";
1100
1106
  const base = name.replace(/(?<=.)\.[^.]*$/, "").normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{M}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "file";
@@ -5460,6 +5466,21 @@ var MediaFolders = class {
5460
5466
  const rows = await this.fresh();
5461
5467
  return this.within(rows, [id]).map((f) => ({ id: f, private: this.privateIn(rows, f) }));
5462
5468
  }
5469
+ /**
5470
+ * The privacy each folder of a subtree would have after a change to its top folder: made
5471
+ * private or public, moved, or (as `private: false`) deleted with its contents moving up.
5472
+ */
5473
+ async privacyAfter(id, change) {
5474
+ const rows = new Map(await this.fresh());
5475
+ const row = rows.get(String(id));
5476
+ if (!row) return [];
5477
+ rows.set(String(id), {
5478
+ ...row,
5479
+ ...change.private !== void 0 ? { private: change.private } : {},
5480
+ ...change.parent !== void 0 ? { parent: change.parent } : {}
5481
+ });
5482
+ return this.within(rows, [id]).map((f) => ({ id: f, private: this.privateIn(rows, f) }));
5483
+ }
5463
5484
  /**
5464
5485
  * The folder with this key (`folder: 'banners'` on upload fields), made at the top level when
5465
5486
  * there is none yet.
@@ -6059,12 +6080,7 @@ var EasyCMS = class {
6059
6080
  async upload(file, data = {}, options = {}) {
6060
6081
  const config = this.collection(MEDIA);
6061
6082
  const guard = guardOf(options);
6062
- if (guard.enforce) {
6063
- await this.checkGrant(guard, { collection: MEDIA }, "create");
6064
- const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data });
6065
- if (allowed2 !== true) throw deny(guard.user);
6066
- await this.folders.checkTarget(guard.user, MEDIA, folderOf(MEDIA, data));
6067
- }
6083
+ await this.checkUpload(guard, data, folderOf(MEDIA, data));
6068
6084
  const { maxFileSize, mimeTypes, imageSizes } = this.config.upload;
6069
6085
  if (file.data.byteLength > maxFileSize) {
6070
6086
  throw new PayloadTooLargeError(`File is larger than ${maxFileSize} bytes`);
@@ -6121,6 +6137,150 @@ var EasyCMS = class {
6121
6137
  throw error;
6122
6138
  }
6123
6139
  }
6140
+ /**
6141
+ * Starts an upload straight to the storage, for files too large to send through the server
6142
+ * (`POST <api>/media/uploads`): checks who uploads what where, as `upload()` does, and returns
6143
+ * a signed ticket and where to send the file. `upload` is `null` when the storage can't take
6144
+ * files directly: send it to `upload()` instead. Then `completeUpload(ticket)`.
6145
+ */
6146
+ async createUpload(file, data = {}, options = {}) {
6147
+ const guard = guardOf(options);
6148
+ const folder = folderOf(MEDIA, data);
6149
+ await this.checkUpload(guard, data, folder);
6150
+ const { maxFileSize, mimeTypes } = this.config.upload;
6151
+ const size = Number(file.size);
6152
+ if (!Number.isInteger(size) || size <= 0)
6153
+ throw new ValidationError(MEDIA, [{ field: "size", message: "must be the size in bytes" }]);
6154
+ if (size > maxFileSize)
6155
+ throw new PayloadTooLargeError(`File is larger than ${maxFileSize} bytes`);
6156
+ const name = String(file.name ?? "").slice(0, 255);
6157
+ const type = typeFromName(name) ?? file.type ?? "";
6158
+ if (!EXTENSIONS[type] || !mimeAllowed(type, mimeTypes))
6159
+ throw new ValidationError(MEDIA, [
6160
+ {
6161
+ field: "file",
6162
+ message: `file type ${type || "unknown"} is not allowed (allowed: ${mimeTypes.join(", ")})`
6163
+ }
6164
+ ]);
6165
+ const isPrivate = await this.folders.isPrivate(folder);
6166
+ const storage = this.storageFor(isPrivate);
6167
+ const key2 = withPrivacy(storageKey(name, type, randomBytes5(4).toString("hex")), isPrivate);
6168
+ const upload = storage.uploadURL ? await storage.uploadURL(key2, { contentType: type, size, expiresIn: UPLOAD_URL_SECONDS }) : null;
6169
+ const ticket = this.sign("upload", {
6170
+ key: key2,
6171
+ private: isPrivate,
6172
+ name,
6173
+ size,
6174
+ type,
6175
+ user: guard.user?.id ?? null,
6176
+ expires: Math.floor(Date.now() / 1e3) + UPLOAD_TICKET_SECONDS,
6177
+ data
6178
+ });
6179
+ return { ticket, upload };
6180
+ }
6181
+ /**
6182
+ * Finishes an upload made with `createUpload()` (`POST <api>/media/uploads/complete`): checks
6183
+ * the file in the storage like any upload (its size, and its type from its contents), then
6184
+ * makes its media document. A file that fails is deleted.
6185
+ */
6186
+ async completeUpload(ticket, options = {}) {
6187
+ const guard = guardOf(options);
6188
+ const t = this.verify("upload", ticket);
6189
+ if (!t || t.expires < Date.now() / 1e3)
6190
+ throw new ValidationError(MEDIA, [
6191
+ { field: "ticket", message: "is not valid or has expired" }
6192
+ ]);
6193
+ if (guard.enforce && String(guard.user?.id ?? null) !== String(t.user)) throw deny(guard.user);
6194
+ await this.checkUpload(guard, t.data, folderOf(MEDIA, t.data));
6195
+ if (await this.db.count({ collection: MEDIA, where: { filename: { equals: t.key } } }) > 0)
6196
+ throw new ValidationError(MEDIA, [{ field: "ticket", message: "was used already" }]);
6197
+ const storage = this.storageFor(t.private);
6198
+ const start = storage.getStart ? await storage.getStart(t.key, SNIFF_BYTES) : await storage.get(t.key);
6199
+ if (!start) throw new ValidationError(MEDIA, [{ field: "file", message: "was not uploaded" }]);
6200
+ const refuse = async (message2) => {
6201
+ await storage.delete(t.key).catch(() => {
6202
+ });
6203
+ throw new ValidationError(MEDIA, [{ field: "file", message: message2 }]);
6204
+ };
6205
+ if (start.size !== t.size) await refuse(`is ${start.size} bytes, not the ${t.size} announced`);
6206
+ if (start.size > this.config.upload.maxFileSize)
6207
+ await refuse(`is larger than ${this.config.upload.maxFileSize} bytes`);
6208
+ const mimeType = sniffMimeType(start.body.subarray(0, SNIFF_BYTES), t.name);
6209
+ if (mimeType !== t.type)
6210
+ await refuse(`contains ${mimeType ?? "an unknown type"}, not ${t.type}`);
6211
+ const stored = [];
6212
+ try {
6213
+ let dimensions;
6214
+ let sizes = {};
6215
+ if (t.type.startsWith("image/")) {
6216
+ const whole = await storage.get(t.key);
6217
+ if (whole) {
6218
+ dimensions = imageDimensions(whole.body, t.type);
6219
+ sizes = await this.resizeImage(
6220
+ whole.body,
6221
+ t.type,
6222
+ t.key,
6223
+ this.config.upload.imageSizes,
6224
+ stored,
6225
+ storage
6226
+ );
6227
+ }
6228
+ }
6229
+ const doc = await this.createDocument(
6230
+ this.collection(MEDIA),
6231
+ {
6232
+ ...t.data,
6233
+ ...this.folders.enabled ? { private: t.private } : {},
6234
+ filename: t.key,
6235
+ originalName: t.name,
6236
+ mimeType: t.type,
6237
+ filesize: start.size,
6238
+ width: dimensions?.width ?? null,
6239
+ height: dimensions?.height ?? null,
6240
+ sizes
6241
+ },
6242
+ { ...options, overrideAccess: true },
6243
+ guard
6244
+ );
6245
+ return doc;
6246
+ } catch (error) {
6247
+ for (const key2 of [t.key, ...stored]) await storage.delete(key2).catch(() => {
6248
+ });
6249
+ throw error;
6250
+ }
6251
+ }
6252
+ /** Who may upload, and into which folder: `upload()` and direct uploads. */
6253
+ async checkUpload(guard, data, folder) {
6254
+ if (!guard.enforce) return;
6255
+ await this.checkGrant(guard, { collection: MEDIA }, "create");
6256
+ const allowed2 = await evaluateAccess(this.collection(MEDIA).access?.create, {
6257
+ user: guard.user,
6258
+ data
6259
+ });
6260
+ if (allowed2 !== true) throw deny(guard.user);
6261
+ await this.folders.checkTarget(guard.user, MEDIA, folder);
6262
+ }
6263
+ /** A value with its HMAC (`secret`), for `purpose` only: `<base64url JSON>.<signature>`. */
6264
+ sign(purpose, value) {
6265
+ const payload = Buffer.from(JSON.stringify(value)).toString("base64url");
6266
+ const mac = createHmac5("sha256", this.config.secret).update(`${purpose}:${payload}`);
6267
+ return `${payload}.${mac.digest("base64url")}`;
6268
+ }
6269
+ /** The value of a token made by `sign`, or `undefined` when it isn't genuine. */
6270
+ verify(purpose, token) {
6271
+ const [payload, signature] = String(token).split(".");
6272
+ if (!payload || !signature) return void 0;
6273
+ const expected = Buffer.from(
6274
+ createHmac5("sha256", this.config.secret).update(`${purpose}:${payload}`).digest("base64url")
6275
+ );
6276
+ const given = Buffer.from(signature);
6277
+ if (expected.length !== given.length || !timingSafeEqual4(expected, given)) return void 0;
6278
+ try {
6279
+ return JSON.parse(Buffer.from(payload, "base64url").toString());
6280
+ } catch {
6281
+ return void 0;
6282
+ }
6283
+ }
6124
6284
  /**
6125
6285
  * Downloads a file from an `http(s)` link and uploads it like `upload()`. Private network
6126
6286
  * addresses are refused unless `upload.fromURL.allowPrivate`. Calls on behalf of a user (the
@@ -6299,6 +6459,28 @@ var EasyCMS = class {
6299
6459
  }
6300
6460
  ]);
6301
6461
  }
6462
+ /**
6463
+ * Refuses a change that would move more than 200 files between storages in one request: they
6464
+ * are copied one by one, which could outlast a serverless function.
6465
+ */
6466
+ async checkMoveLimit(folders) {
6467
+ let count = 0;
6468
+ for (const f of folders)
6469
+ count += await this.db.count({
6470
+ collection: MEDIA,
6471
+ where: andWhere(
6472
+ { folder: { equals: f.id } },
6473
+ f.private ? PUBLIC_FILES : { private: { equals: true } }
6474
+ )
6475
+ });
6476
+ if (count > MAX_FILES_TO_MOVE)
6477
+ throw new ValidationError(MEDIA_FOLDERS, [
6478
+ {
6479
+ field: "private",
6480
+ message: `${count} files would move between public and private storage; move at most ${MAX_FILES_TO_MOVE} at a time (folder by folder)`
6481
+ }
6482
+ ]);
6483
+ }
6302
6484
  /** After a folder became private or public (or moved): its files follow, in every subfolder. */
6303
6485
  async settlePrivacy(folder) {
6304
6486
  for (const f of await this.folders.subtree(folder))
@@ -6603,7 +6785,16 @@ var EasyCMS = class {
6603
6785
  }
6604
6786
  if (config.slug === USERS) await this.guardLastAdmin(parsed, existing, prepared);
6605
6787
  if (password !== void 0) prepared.passwordHash = await hashPassword(password);
6606
- if (collection2 === MEDIA_FOLDERS) await this.validateFolder(prepared, parsed);
6788
+ if (collection2 === MEDIA_FOLDERS) {
6789
+ await this.validateFolder(prepared, parsed);
6790
+ if (prepared.private !== existing.private || String(prepared.parent ?? null) !== String(existing.parent ?? null))
6791
+ await this.checkMoveLimit(
6792
+ await this.folders.privacyAfter(parsed, {
6793
+ private: prepared.private === true,
6794
+ parent: prepared.parent ?? null
6795
+ })
6796
+ );
6797
+ }
6607
6798
  let moved;
6608
6799
  if (collection2 === MEDIA && this.folders.enabled) {
6609
6800
  const toPrivate = await this.folders.isPrivate(folderOf(MEDIA, prepared));
@@ -6665,7 +6856,10 @@ var EasyCMS = class {
6665
6856
  await this.auth.revokeSessions(parsed);
6666
6857
  await this.auth.sso.forget(parsed);
6667
6858
  }
6668
- if (collection2 === MEDIA_FOLDERS) await this.folders.release(existing);
6859
+ if (collection2 === MEDIA_FOLDERS) {
6860
+ await this.checkMoveLimit(await this.folders.privacyAfter(parsed, { private: false }));
6861
+ await this.folders.release(existing);
6862
+ }
6669
6863
  await this.db.delete({ collection: collection2, id: parsed });
6670
6864
  if (collection2 === MEDIA_FOLDERS) {
6671
6865
  this.folders.invalidate();
@@ -7838,6 +8032,10 @@ function folderOf(collection2, data) {
7838
8032
  return value === void 0 || value === "" ? null : value;
7839
8033
  }
7840
8034
  var MAX_LINK = 7 * 24 * 60 * 60;
8035
+ var UPLOAD_URL_SECONDS = 15 * 60;
8036
+ var UPLOAD_TICKET_SECONDS = 24 * 60 * 60;
8037
+ var SNIFF_BYTES = 64 * 1024;
8038
+ var MAX_FILES_TO_MOVE = 200;
7841
8039
  function durationSeconds(value) {
7842
8040
  const match = typeof value === "string" ? /^(\d+)\s*([smhd])$/.exec(value.trim()) : null;
7843
8041
  const seconds = typeof value === "number" ? value : match ? Number(match[1]) * { s: 1, m: 60, h: 3600, d: 86400 }[match[2]] : Number.NaN;
@@ -8339,7 +8537,7 @@ function folderIds(cms) {
8339
8537
  }
8340
8538
 
8341
8539
  // src/version.ts
8342
- var VERSION = true ? "0.40.0" : "0.0.0-dev";
8540
+ var VERSION = true ? "0.41.0" : "0.0.0-dev";
8343
8541
 
8344
8542
  // src/rest/admin-status.ts
8345
8543
  var DAY2 = 864e5;
@@ -9064,6 +9262,30 @@ async function route(cms, ctx, method, segments, options) {
9064
9262
  if (method !== "GET" && method !== "HEAD") throw methodNotAllowed(ctx, "GET, HEAD");
9065
9263
  return { body: await servePrivateFile(cms, ctx, third, method === "HEAD") };
9066
9264
  }
9265
+ if (first === MEDIA && second === "uploads" && segments.length <= 3) {
9266
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
9267
+ const body = await readJson(ctx.request);
9268
+ if (third === "complete" && segments.length === 3) {
9269
+ if (typeof body.ticket !== "string" || body.ticket === "")
9270
+ throw new ValidationError(MEDIA, [{ field: "ticket", message: "is required" }]);
9271
+ return {
9272
+ status: 201,
9273
+ body: await cms.completeUpload(body.ticket, { ...access, ...parseDepth(ctx.url) })
9274
+ };
9275
+ }
9276
+ if (third !== void 0) throw new HttpError("Not found", 404);
9277
+ const { name, size, type, ...data } = body;
9278
+ if (typeof name !== "string" || name === "")
9279
+ throw new ValidationError(MEDIA, [{ field: "name", message: "is required" }]);
9280
+ return {
9281
+ status: 201,
9282
+ body: await cms.createUpload(
9283
+ { name, size: Number(size), ...typeof type === "string" ? { type } : {} },
9284
+ data,
9285
+ access
9286
+ )
9287
+ };
9288
+ }
9067
9289
  if (first === MEDIA && second === void 0 && method === "POST") {
9068
9290
  if ((ctx.request.headers.get("content-type") ?? "").toLowerCase().startsWith("application/json")) {
9069
9291
  const { url, ...data2 } = await readJson(ctx.request);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "description": "Embedded, code-first headless CMS for Nuxt and Next.js",
5
5
  "license": "MIT",
6
6
  "type": "module",