@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 +65 -1
- package/dist/index.js +231 -9
- package/package.json +1 -1
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
|
-
|
|
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)
|
|
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)
|
|
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.
|
|
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);
|