@easy-cms/core 0.39.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 +196 -6
- package/dist/index.js +684 -35
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -123,9 +123,13 @@ function cleanPermissions(value, known) {
|
|
|
123
123
|
}
|
|
124
124
|
return out;
|
|
125
125
|
};
|
|
126
|
+
const folders = Array.isArray(input.folders) ? input.folders.filter(
|
|
127
|
+
(f) => typeof f === "string" || typeof f === "number"
|
|
128
|
+
) : [];
|
|
126
129
|
return {
|
|
127
130
|
collections: pick(input.collections, known.collections, API_KEY_OPERATIONS),
|
|
128
|
-
globals: pick(input.globals, known.globals, API_KEY_GLOBAL_OPERATIONS)
|
|
131
|
+
globals: pick(input.globals, known.globals, API_KEY_GLOBAL_OPERATIONS),
|
|
132
|
+
...folders.length > 0 ? { folders } : {}
|
|
129
133
|
};
|
|
130
134
|
}
|
|
131
135
|
|
|
@@ -286,6 +290,14 @@ function mediaFields() {
|
|
|
286
290
|
{ name: "alt", type: "text", label: { en: "Alternative text", th: "\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21\u0E41\u0E17\u0E19\u0E23\u0E39\u0E1B" } }
|
|
287
291
|
];
|
|
288
292
|
}
|
|
293
|
+
var privateField = {
|
|
294
|
+
name: "private",
|
|
295
|
+
type: "boolean",
|
|
296
|
+
index: true,
|
|
297
|
+
access: systemField,
|
|
298
|
+
position: "sidebar",
|
|
299
|
+
label: { en: "Private", th: "\u0E2A\u0E48\u0E27\u0E19\u0E15\u0E31\u0E27" }
|
|
300
|
+
};
|
|
289
301
|
var adminField = {
|
|
290
302
|
read: ({ user }) => user?.role === "admin" && !user.apiKey,
|
|
291
303
|
update: ({ user }) => user?.role === "admin" && !user.apiKey
|
|
@@ -324,6 +336,22 @@ var mediaFoldersCollection = {
|
|
|
324
336
|
index: true,
|
|
325
337
|
label: { en: "In folder", th: "\u0E2D\u0E22\u0E39\u0E48\u0E43\u0E19\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C" }
|
|
326
338
|
},
|
|
339
|
+
// For code to find the folder (`folder: 'banners'` on upload fields); set when it is made.
|
|
340
|
+
{
|
|
341
|
+
name: "key",
|
|
342
|
+
type: "text",
|
|
343
|
+
unique: true,
|
|
344
|
+
maxLength: 60,
|
|
345
|
+
access: { update: ({ id }) => id === void 0 },
|
|
346
|
+
label: { en: "Key", th: "\u0E04\u0E35\u0E22\u0E4C" }
|
|
347
|
+
},
|
|
348
|
+
// Its files and subfolders are private: only for users who may see them, and signed links.
|
|
349
|
+
{
|
|
350
|
+
name: "private",
|
|
351
|
+
type: "boolean",
|
|
352
|
+
access: { update: adminField.update },
|
|
353
|
+
label: { en: "Private", th: "\u0E2A\u0E48\u0E27\u0E19\u0E15\u0E31\u0E27" }
|
|
354
|
+
},
|
|
327
355
|
// `null`: as the parent folder. Otherwise each role's level (`view`, `edit`, `manage`);
|
|
328
356
|
// roles not listed get nothing.
|
|
329
357
|
{
|
|
@@ -351,7 +379,11 @@ function withMedia(config) {
|
|
|
351
379
|
icon: "image",
|
|
352
380
|
useAsTitle: "filename",
|
|
353
381
|
...custom,
|
|
354
|
-
fields: [
|
|
382
|
+
fields: [
|
|
383
|
+
...mediaFields(),
|
|
384
|
+
...folders ? [folderField, privateField] : [],
|
|
385
|
+
...custom?.fields ?? []
|
|
386
|
+
],
|
|
355
387
|
access: {
|
|
356
388
|
read: anyone,
|
|
357
389
|
create: isLoggedIn,
|
|
@@ -380,8 +412,9 @@ function withMedia(config) {
|
|
|
380
412
|
afterDelete: [
|
|
381
413
|
async ({ doc, cms }) => {
|
|
382
414
|
const sizes = Object.values(doc.sizes ?? {});
|
|
415
|
+
const storage = cms.storageFor(doc.private === true);
|
|
383
416
|
for (const key2 of [doc.filename, ...sizes.map((s) => s.filename)]) {
|
|
384
|
-
if (typeof key2 === "string") await
|
|
417
|
+
if (typeof key2 === "string") await storage.delete(key2);
|
|
385
418
|
}
|
|
386
419
|
},
|
|
387
420
|
...custom?.hooks?.afterDelete ?? []
|
|
@@ -1062,11 +1095,28 @@ function imageDimensions(data, type) {
|
|
|
1062
1095
|
}
|
|
1063
1096
|
return void 0;
|
|
1064
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
|
+
}
|
|
1065
1104
|
function storageKey(originalName, type, random2) {
|
|
1066
1105
|
const name = originalName.split(/[\\/]/).pop() ?? "";
|
|
1067
1106
|
const base = name.replace(/(?<=.)\.[^.]*$/, "").normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{M}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "file";
|
|
1068
1107
|
return `${base}-${random2}.${EXTENSIONS[type] ?? "bin"}`;
|
|
1069
1108
|
}
|
|
1109
|
+
function isPrivateKey(key2) {
|
|
1110
|
+
return /\.private[.-]/.test(key2);
|
|
1111
|
+
}
|
|
1112
|
+
function withPrivacy(key2, isPrivate) {
|
|
1113
|
+
const plain = key2.replace(/\.private(?=\.[^.]+$)/, "");
|
|
1114
|
+
return isPrivate ? plain.replace(/\.([^.]+)$/, ".private.$1") : plain;
|
|
1115
|
+
}
|
|
1116
|
+
function sizeKey(filename, size) {
|
|
1117
|
+
return filename.replace(/\.([^.]+)$/, `-${size}.$1`);
|
|
1118
|
+
}
|
|
1119
|
+
var PRIVATE_KEY = /^[\p{L}\p{M}\p{N}-]+\.private(-[\p{L}\p{N}_-]+)?\.[a-z0-9]+$/u;
|
|
1070
1120
|
|
|
1071
1121
|
// src/fields.ts
|
|
1072
1122
|
var isHasMany = (field) => (field.type === "select" || field.type === "relationship" || field.type === "upload") && field.hasMany === true;
|
|
@@ -2806,7 +2856,8 @@ async function normalizeValue(field, raw, path, options, fail, errors, reference
|
|
|
2806
2856
|
collection: target,
|
|
2807
2857
|
id,
|
|
2808
2858
|
...field.type === "relationship" && field.filterOptions ? { filterOptions: field.filterOptions } : {},
|
|
2809
|
-
...field.type === "upload" && field.mimeTypes ? { mimeTypes: field.mimeTypes } : {}
|
|
2859
|
+
...field.type === "upload" && field.mimeTypes ? { mimeTypes: field.mimeTypes } : {},
|
|
2860
|
+
...field.type === "upload" && field.folder && field.folderOnly ? { folderOnly: field.folder } : {}
|
|
2810
2861
|
});
|
|
2811
2862
|
}
|
|
2812
2863
|
return many ? ids : ids[0];
|
|
@@ -4503,6 +4554,25 @@ function validateCors(cors, add) {
|
|
|
4503
4554
|
}
|
|
4504
4555
|
function validateUpload(config, add) {
|
|
4505
4556
|
const upload = config.upload;
|
|
4557
|
+
if (upload?.folders !== true) {
|
|
4558
|
+
const walk = (fields, path) => {
|
|
4559
|
+
for (const [i, field] of fields.entries()) {
|
|
4560
|
+
const at = `${path}.fields[${i}]`;
|
|
4561
|
+
if (field.type === "upload" && field.folder !== void 0)
|
|
4562
|
+
add(`${at}.folder`, "needs upload.folders: true", "upload: { folders: true }");
|
|
4563
|
+
if ((field.type === "group" || field.type === "array") && Array.isArray(field.fields))
|
|
4564
|
+
walk(field.fields, at);
|
|
4565
|
+
if (field.type === "blocks" && Array.isArray(field.blocks)) {
|
|
4566
|
+
for (const [b, block] of field.blocks.entries())
|
|
4567
|
+
if (Array.isArray(block?.fields)) walk(block.fields, `${at}.blocks[${b}]`);
|
|
4568
|
+
}
|
|
4569
|
+
}
|
|
4570
|
+
};
|
|
4571
|
+
for (const [i, c] of (config.collections ?? []).entries())
|
|
4572
|
+
if (Array.isArray(c?.fields)) walk(c.fields, `collections[${i}]`);
|
|
4573
|
+
for (const [i, g] of (config.globals ?? []).entries())
|
|
4574
|
+
if (Array.isArray(g?.fields)) walk(g.fields, `globals[${i}]`);
|
|
4575
|
+
}
|
|
4506
4576
|
if (upload === void 0) return;
|
|
4507
4577
|
if (upload.maxFileSize !== void 0 && !(Number.isInteger(upload.maxFileSize) && upload.maxFileSize > 0)) {
|
|
4508
4578
|
add("upload.maxFileSize", "must be a positive integer (bytes)");
|
|
@@ -4519,6 +4589,9 @@ function validateUpload(config, add) {
|
|
|
4519
4589
|
);
|
|
4520
4590
|
if (upload.folders !== void 0 && typeof upload.folders !== "boolean")
|
|
4521
4591
|
add("upload.folders", "must be true or false");
|
|
4592
|
+
const privateStorage = upload.privateStorage;
|
|
4593
|
+
if (privateStorage !== void 0 && (typeof privateStorage !== "object" || privateStorage === null || typeof privateStorage.get !== "function"))
|
|
4594
|
+
add("upload.privateStorage", "must be a storage adapter", "e.g. s3Storage({ \u2026 })");
|
|
4522
4595
|
const fromURL = upload.fromURL;
|
|
4523
4596
|
if (fromURL === void 0) return;
|
|
4524
4597
|
if (typeof fromURL !== "object" || fromURL === null) {
|
|
@@ -4544,6 +4617,7 @@ function validateUpload(config, add) {
|
|
|
4544
4617
|
if (allowPrivate !== void 0 && typeof allowPrivate !== "boolean")
|
|
4545
4618
|
add("upload.fromURL.allowPrivate", "must be true or false");
|
|
4546
4619
|
}
|
|
4620
|
+
var FOLDER_KEY = /^[a-z0-9][a-z0-9_-]{0,59}$/;
|
|
4547
4621
|
var MIME_PATTERN = /^([\w.+-]+\/(\*|[\w.+-]+)|documents|office|archives)$/i;
|
|
4548
4622
|
var HOST_PATTERN = /^(\*|(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*)$/i;
|
|
4549
4623
|
function validateAudit(config, add) {
|
|
@@ -4792,6 +4866,14 @@ function validateField(field, path, siblings, collectionSlugs, add) {
|
|
|
4792
4866
|
break;
|
|
4793
4867
|
case "upload":
|
|
4794
4868
|
validateManyRange(field, path, add);
|
|
4869
|
+
if (field.folder !== void 0 && !FOLDER_KEY.test(String(field.folder)))
|
|
4870
|
+
add(
|
|
4871
|
+
`${path}.folder`,
|
|
4872
|
+
'must be a folder key: lowercase letters, digits, "-" or "_"',
|
|
4873
|
+
"e.g. folder: 'banners'"
|
|
4874
|
+
);
|
|
4875
|
+
if (field.folderOnly !== void 0 && field.folder === void 0)
|
|
4876
|
+
add(`${path}.folderOnly`, "needs folder");
|
|
4795
4877
|
if (field.mimeTypes !== void 0) {
|
|
4796
4878
|
const types = field.mimeTypes;
|
|
4797
4879
|
if (!Array.isArray(types) || types.length === 0 || !types.every((t) => typeof t === "string" && MIME_PATTERN.test(t))) {
|
|
@@ -5070,7 +5152,8 @@ async function resolveConfig(input) {
|
|
|
5070
5152
|
imageSizes: config.upload?.imageSizes ?? [],
|
|
5071
5153
|
folders: config.upload?.folders === true,
|
|
5072
5154
|
...config.upload?.storage ? { storage: config.upload.storage } : {},
|
|
5073
|
-
...config.upload?.fromURL ? { fromURL: config.upload.fromURL } : {}
|
|
5155
|
+
...config.upload?.fromURL ? { fromURL: config.upload.fromURL } : {},
|
|
5156
|
+
...config.upload?.privateStorage ? { privateStorage: config.upload.privateStorage } : {}
|
|
5074
5157
|
},
|
|
5075
5158
|
auth: {
|
|
5076
5159
|
roles: config.auth?.roles ?? DEFAULT_ROLES,
|
|
@@ -5173,7 +5256,7 @@ async function loadConfig(options = {}) {
|
|
|
5173
5256
|
}
|
|
5174
5257
|
|
|
5175
5258
|
// src/local-api.ts
|
|
5176
|
-
import { randomBytes as randomBytes5 } from "crypto";
|
|
5259
|
+
import { createHmac as createHmac5, randomBytes as randomBytes5, timingSafeEqual as timingSafeEqual4 } from "crypto";
|
|
5177
5260
|
|
|
5178
5261
|
// src/localization.ts
|
|
5179
5262
|
var ALL_LOCALES = "all";
|
|
@@ -5286,6 +5369,14 @@ var silentLogger = { info() {
|
|
|
5286
5369
|
var FOLDER_LEVELS = ["view", "edit", "manage"];
|
|
5287
5370
|
var RANK = { none: 0, view: 1, edit: 2, manage: 3 };
|
|
5288
5371
|
var TTL2 = 1e4;
|
|
5372
|
+
var PUBLIC_FILES = {
|
|
5373
|
+
or: [{ private: { equals: false } }, { private: { exists: false } }]
|
|
5374
|
+
};
|
|
5375
|
+
function keyFolders(user) {
|
|
5376
|
+
const key2 = user?.apiKey;
|
|
5377
|
+
const folders = key2?.permissions?.folders;
|
|
5378
|
+
return folders && folders.length > 0 ? folders : void 0;
|
|
5379
|
+
}
|
|
5289
5380
|
function needed(collection2, operation) {
|
|
5290
5381
|
if (operation === "read") return "view";
|
|
5291
5382
|
return collection2 === MEDIA ? "edit" : "manage";
|
|
@@ -5318,6 +5409,8 @@ var MediaFolders = class {
|
|
|
5318
5409
|
id: doc.id,
|
|
5319
5410
|
parent: doc.parent ?? null,
|
|
5320
5411
|
name: String(doc.name ?? ""),
|
|
5412
|
+
key: typeof doc.key === "string" && doc.key ? doc.key : null,
|
|
5413
|
+
private: doc.private === true,
|
|
5321
5414
|
permissions: permissions && typeof permissions === "object" && !Array.isArray(permissions) ? permissions : null
|
|
5322
5415
|
});
|
|
5323
5416
|
}
|
|
@@ -5338,6 +5431,98 @@ var MediaFolders = class {
|
|
|
5338
5431
|
}
|
|
5339
5432
|
return "manage";
|
|
5340
5433
|
}
|
|
5434
|
+
/** Whether files in a folder (`null`: the top level) are private: it or a folder above is. */
|
|
5435
|
+
privateIn(rows, id) {
|
|
5436
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5437
|
+
let current = id === null ? void 0 : rows.get(String(id));
|
|
5438
|
+
while (current && !seen.has(String(current.id))) {
|
|
5439
|
+
if (current.private) return true;
|
|
5440
|
+
seen.add(String(current.id));
|
|
5441
|
+
current = current.parent === null ? void 0 : rows.get(String(current.parent));
|
|
5442
|
+
}
|
|
5443
|
+
return false;
|
|
5444
|
+
}
|
|
5445
|
+
/** Whether files put in this folder are private. */
|
|
5446
|
+
async isPrivate(id) {
|
|
5447
|
+
if (!this.enabled || id === null) return false;
|
|
5448
|
+
return this.privateIn(await this.rows(), id);
|
|
5449
|
+
}
|
|
5450
|
+
/** The folder and every folder inside it. */
|
|
5451
|
+
within(rows, ids) {
|
|
5452
|
+
const out = new Set(ids.map(String));
|
|
5453
|
+
let grew = true;
|
|
5454
|
+
while (grew) {
|
|
5455
|
+
grew = false;
|
|
5456
|
+
for (const row of rows.values())
|
|
5457
|
+
if (row.parent !== null && out.has(String(row.parent)) && !out.has(String(row.id))) {
|
|
5458
|
+
out.add(String(row.id));
|
|
5459
|
+
grew = true;
|
|
5460
|
+
}
|
|
5461
|
+
}
|
|
5462
|
+
return [...rows.values()].filter((r) => out.has(String(r.id))).map((r) => r.id);
|
|
5463
|
+
}
|
|
5464
|
+
/** Folders whose privacy follows this one: it and those inside, with their privacy now. */
|
|
5465
|
+
async subtree(id) {
|
|
5466
|
+
const rows = await this.fresh();
|
|
5467
|
+
return this.within(rows, [id]).map((f) => ({ id: f, private: this.privateIn(rows, f) }));
|
|
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
|
+
}
|
|
5484
|
+
/**
|
|
5485
|
+
* The folder with this key (`folder: 'banners'` on upload fields), made at the top level when
|
|
5486
|
+
* there is none yet.
|
|
5487
|
+
*/
|
|
5488
|
+
async keyed(key2) {
|
|
5489
|
+
const find = async () => [...(await this.rows()).values()].find((r) => r.key === key2);
|
|
5490
|
+
let found = await find();
|
|
5491
|
+
if (!found) {
|
|
5492
|
+
this.invalidate();
|
|
5493
|
+
found = await find();
|
|
5494
|
+
}
|
|
5495
|
+
if (found) return found.id;
|
|
5496
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
5497
|
+
try {
|
|
5498
|
+
const doc = await this.db.create({
|
|
5499
|
+
collection: MEDIA_FOLDERS,
|
|
5500
|
+
data: {
|
|
5501
|
+
name: key2,
|
|
5502
|
+
key: key2,
|
|
5503
|
+
parent: null,
|
|
5504
|
+
permissions: null,
|
|
5505
|
+
private: false,
|
|
5506
|
+
createdAt: now,
|
|
5507
|
+
updatedAt: now
|
|
5508
|
+
}
|
|
5509
|
+
});
|
|
5510
|
+
this.invalidate();
|
|
5511
|
+
return doc.id;
|
|
5512
|
+
} catch (error) {
|
|
5513
|
+
this.invalidate();
|
|
5514
|
+
const again = await find();
|
|
5515
|
+
if (again) return again.id;
|
|
5516
|
+
throw error;
|
|
5517
|
+
}
|
|
5518
|
+
}
|
|
5519
|
+
/** Whether a file in `folder` is in the folder with this key, or below it. */
|
|
5520
|
+
async inKeyed(key2, folder) {
|
|
5521
|
+
if (folder === null) return false;
|
|
5522
|
+
const rows = await this.rows();
|
|
5523
|
+
const root = [...rows.values()].find((r) => r.key === key2);
|
|
5524
|
+
return !!root && this.within(rows, [root.id]).some((id) => String(id) === String(folder));
|
|
5525
|
+
}
|
|
5341
5526
|
/** The user's level in a folder (`null`: the top level). */
|
|
5342
5527
|
async level(user, id) {
|
|
5343
5528
|
if (!this.applies(user)) return "manage";
|
|
@@ -5348,7 +5533,15 @@ var MediaFolders = class {
|
|
|
5348
5533
|
* they add none (no folder limits this user).
|
|
5349
5534
|
*/
|
|
5350
5535
|
async where(user, collection2, operation) {
|
|
5351
|
-
if (collection2 !== MEDIA && collection2 !== MEDIA_FOLDERS
|
|
5536
|
+
if (!this.enabled || collection2 !== MEDIA && collection2 !== MEDIA_FOLDERS) return true;
|
|
5537
|
+
if (!user) return collection2 === MEDIA ? PUBLIC_FILES : true;
|
|
5538
|
+
const limited = keyFolders(user);
|
|
5539
|
+
if (limited) {
|
|
5540
|
+
const ids = this.within(await this.rows(), limited);
|
|
5541
|
+
if (collection2 === MEDIA_FOLDERS) return { id: { in: ids } };
|
|
5542
|
+
return operation === "create" ? true : { folder: { in: ids } };
|
|
5543
|
+
}
|
|
5544
|
+
if (!this.applies(user)) return true;
|
|
5352
5545
|
if (operation === "create") return true;
|
|
5353
5546
|
const need = RANK[needed(collection2, operation)];
|
|
5354
5547
|
const rows = await this.rows();
|
|
@@ -5365,6 +5558,13 @@ var MediaFolders = class {
|
|
|
5365
5558
|
}
|
|
5366
5559
|
/** Refuses putting a file (`media`) or a subfolder in a folder the user may not. */
|
|
5367
5560
|
async checkTarget(user, collection2, folder) {
|
|
5561
|
+
const limited = keyFolders(user);
|
|
5562
|
+
if (limited) {
|
|
5563
|
+
const ids = this.within(await this.rows(), limited).map(String);
|
|
5564
|
+
if (folder === null || !ids.includes(String(folder)))
|
|
5565
|
+
throw new ForbiddenError("This API key may not add to this folder");
|
|
5566
|
+
return;
|
|
5567
|
+
}
|
|
5368
5568
|
if (!this.applies(user)) return;
|
|
5369
5569
|
const need = collection2 === MEDIA ? "edit" : "manage";
|
|
5370
5570
|
if (RANK[await this.level(user, folder)] < RANK[need])
|
|
@@ -5729,6 +5929,7 @@ async function createEasyCMS(config, options = {}) {
|
|
|
5729
5929
|
const cwd = options.cwd ?? process.cwd();
|
|
5730
5930
|
const storage = resolved3.upload.storage ?? localStorage({ dir: resolved3.upload.dir });
|
|
5731
5931
|
await storage.init?.({ cwd });
|
|
5932
|
+
if (resolved3.upload.privateStorage) await resolved3.upload.privateStorage.init?.({ cwd });
|
|
5732
5933
|
const db = await resolved3.db.init({
|
|
5733
5934
|
config: resolved3,
|
|
5734
5935
|
cwd,
|
|
@@ -5757,6 +5958,11 @@ var EasyCMS = class {
|
|
|
5757
5958
|
auth;
|
|
5758
5959
|
/** Where uploaded files are stored. */
|
|
5759
5960
|
storage;
|
|
5961
|
+
/**
|
|
5962
|
+
* Where files in private folders are stored (`upload.privateStorage`, else `storage` when it has
|
|
5963
|
+
* no public URLs); `null` when private folders can't be used.
|
|
5964
|
+
*/
|
|
5965
|
+
privateStorage;
|
|
5760
5966
|
/** Project root: relative paths in the config (e.g. `admin.modules`) start here. */
|
|
5761
5967
|
cwd;
|
|
5762
5968
|
/** Roles and their permissions (`auth.rbac`, Settings → Roles). */
|
|
@@ -5768,11 +5974,12 @@ var EasyCMS = class {
|
|
|
5768
5974
|
versions;
|
|
5769
5975
|
webhooks;
|
|
5770
5976
|
mailer;
|
|
5771
|
-
constructor(config, db, logger = consoleLogger, storage = localStorage({ dir: config.upload.dir }), cwd = process.cwd()) {
|
|
5977
|
+
constructor(config, db, logger = consoleLogger, storage = localStorage({ dir: config.upload.dir }), cwd = process.cwd(), privateStorage) {
|
|
5772
5978
|
this.config = config;
|
|
5773
5979
|
this.db = db;
|
|
5774
5980
|
this.logger = logger;
|
|
5775
5981
|
this.storage = storage;
|
|
5982
|
+
this.privateStorage = privateStorage !== void 0 ? privateStorage : config.upload.privateStorage ?? (typeof storage.url === "function" ? null : storage);
|
|
5776
5983
|
this.cwd = cwd;
|
|
5777
5984
|
this.versions = new VersionStore(db);
|
|
5778
5985
|
this.roles = new Roles(
|
|
@@ -5873,12 +6080,7 @@ var EasyCMS = class {
|
|
|
5873
6080
|
async upload(file, data = {}, options = {}) {
|
|
5874
6081
|
const config = this.collection(MEDIA);
|
|
5875
6082
|
const guard = guardOf(options);
|
|
5876
|
-
|
|
5877
|
-
await this.checkGrant(guard, { collection: MEDIA }, "create");
|
|
5878
|
-
const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data });
|
|
5879
|
-
if (allowed2 !== true) throw deny(guard.user);
|
|
5880
|
-
await this.folders.checkTarget(guard.user, MEDIA, folderOf(MEDIA, data));
|
|
5881
|
-
}
|
|
6083
|
+
await this.checkUpload(guard, data, folderOf(MEDIA, data));
|
|
5882
6084
|
const { maxFileSize, mimeTypes, imageSizes } = this.config.upload;
|
|
5883
6085
|
if (file.data.byteLength > maxFileSize) {
|
|
5884
6086
|
throw new PayloadTooLargeError(`File is larger than ${maxFileSize} bytes`);
|
|
@@ -5895,17 +6097,27 @@ var EasyCMS = class {
|
|
|
5895
6097
|
]);
|
|
5896
6098
|
}
|
|
5897
6099
|
const random2 = randomBytes5(4).toString("hex");
|
|
5898
|
-
const
|
|
6100
|
+
const isPrivate = await this.folders.isPrivate(folderOf(MEDIA, data));
|
|
6101
|
+
const storage = this.storageFor(isPrivate);
|
|
6102
|
+
const filename = withPrivacy(storageKey(file.name, mimeType, random2), isPrivate);
|
|
5899
6103
|
const stored = [];
|
|
5900
6104
|
try {
|
|
5901
|
-
await
|
|
6105
|
+
await storage.put(filename, file.data, { contentType: mimeType });
|
|
5902
6106
|
stored.push(filename);
|
|
5903
6107
|
const dimensions = imageDimensions(file.data, mimeType);
|
|
5904
|
-
const sizes = await this.resizeImage(
|
|
6108
|
+
const sizes = await this.resizeImage(
|
|
6109
|
+
file.data,
|
|
6110
|
+
mimeType,
|
|
6111
|
+
filename,
|
|
6112
|
+
imageSizes,
|
|
6113
|
+
stored,
|
|
6114
|
+
storage
|
|
6115
|
+
);
|
|
5905
6116
|
const doc = await this.createDocument(
|
|
5906
6117
|
config,
|
|
5907
6118
|
{
|
|
5908
6119
|
...data,
|
|
6120
|
+
...this.folders.enabled ? { private: isPrivate } : {},
|
|
5909
6121
|
filename,
|
|
5910
6122
|
originalName: file.name.slice(0, 255),
|
|
5911
6123
|
mimeType,
|
|
@@ -5920,11 +6132,155 @@ var EasyCMS = class {
|
|
|
5920
6132
|
);
|
|
5921
6133
|
return doc;
|
|
5922
6134
|
} catch (error) {
|
|
5923
|
-
for (const key2 of stored) await
|
|
6135
|
+
for (const key2 of stored) await storage.delete(key2).catch(() => {
|
|
5924
6136
|
});
|
|
5925
6137
|
throw error;
|
|
5926
6138
|
}
|
|
5927
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
|
+
}
|
|
5928
6284
|
/**
|
|
5929
6285
|
* Downloads a file from an `http(s)` link and uploads it like `upload()`. Private network
|
|
5930
6286
|
* addresses are refused unless `upload.fromURL.allowPrivate`. Calls on behalf of a user (the
|
|
@@ -5995,9 +6351,159 @@ var EasyCMS = class {
|
|
|
5995
6351
|
}
|
|
5996
6352
|
/** Public URL of a stored file. */
|
|
5997
6353
|
mediaURL(key2) {
|
|
6354
|
+
const base = `${this.config.serverURL?.replace(/\/+$/, "") ?? ""}${this.config.routes.api}`;
|
|
6355
|
+
if (isPrivateKey(key2)) return `${base}/media/private/${encodeURIComponent(key2)}`;
|
|
5998
6356
|
const custom = this.storage.url?.(key2);
|
|
5999
6357
|
if (custom) return custom;
|
|
6000
|
-
return `${
|
|
6358
|
+
return `${base}/media/file/${encodeURIComponent(key2)}`;
|
|
6359
|
+
}
|
|
6360
|
+
/**
|
|
6361
|
+
* A link to a private file that works without signing in until it expires: `expiresIn` in
|
|
6362
|
+
* seconds or as `'30m'`, `'1h'` (the default), `'7d'` (the most). Public files get their usual
|
|
6363
|
+
* URL. `size`: one of the file's resized copies. Links stop working when `secret` changes or
|
|
6364
|
+
* the file stops being private.
|
|
6365
|
+
*/
|
|
6366
|
+
signedMediaURL(doc, options = {}) {
|
|
6367
|
+
const sizes = doc.sizes ?? {};
|
|
6368
|
+
const key2 = options.size ? sizes[options.size]?.filename : doc.filename;
|
|
6369
|
+
if (typeof key2 !== "string" || !key2)
|
|
6370
|
+
throw new QueryError(
|
|
6371
|
+
options.size ? `The file has no "${options.size}" size` : "The document is not a file"
|
|
6372
|
+
);
|
|
6373
|
+
if (!isPrivateKey(key2)) return this.mediaURL(key2);
|
|
6374
|
+
const seconds = durationSeconds(options.expiresIn ?? "1h");
|
|
6375
|
+
const expires = Math.floor(Date.now() / 1e3) + seconds;
|
|
6376
|
+
return `${this.mediaURL(key2)}?expires=${expires}&signature=${this.mediaSignature(key2, expires)}`;
|
|
6377
|
+
}
|
|
6378
|
+
/** Whether a signed link to a private file is genuine and not expired. */
|
|
6379
|
+
verifyMediaSignature(key2, expires, signature) {
|
|
6380
|
+
const at = Number(expires);
|
|
6381
|
+
if (!signature || !Number.isInteger(at) || at < Date.now() / 1e3) return false;
|
|
6382
|
+
const expected = Buffer.from(this.mediaSignature(key2, at));
|
|
6383
|
+
const given = Buffer.from(signature);
|
|
6384
|
+
return expected.length === given.length && timingSafeEqual4(expected, given);
|
|
6385
|
+
}
|
|
6386
|
+
mediaSignature(key2, expires) {
|
|
6387
|
+
return createHmac5("sha256", this.config.secret).update(`media:${key2}:${expires}`).digest("base64url");
|
|
6388
|
+
}
|
|
6389
|
+
/**
|
|
6390
|
+
* How many documents use any of these files in a top-level upload field (rich text and fields
|
|
6391
|
+
* inside groups, arrays and blocks are not counted). Drafts count too.
|
|
6392
|
+
*/
|
|
6393
|
+
async mediaUsage(ids) {
|
|
6394
|
+
if (ids.length === 0) return 0;
|
|
6395
|
+
let count = 0;
|
|
6396
|
+
for (const config of this.config.collections) {
|
|
6397
|
+
if (INTERNAL_COLLECTIONS.has(config.slug)) continue;
|
|
6398
|
+
for (const field of config.fields) {
|
|
6399
|
+
if (field.type !== "upload") continue;
|
|
6400
|
+
count += await this.db.count({
|
|
6401
|
+
collection: config.slug,
|
|
6402
|
+
where: { [field.name]: { in: [...ids] } }
|
|
6403
|
+
});
|
|
6404
|
+
}
|
|
6405
|
+
}
|
|
6406
|
+
return count;
|
|
6407
|
+
}
|
|
6408
|
+
/** The storage for public files, or for private ones. */
|
|
6409
|
+
storageFor(isPrivate) {
|
|
6410
|
+
if (!isPrivate) return this.storage;
|
|
6411
|
+
if (!this.privateStorage)
|
|
6412
|
+
throw new EasyCMSError(
|
|
6413
|
+
"Private folders need upload.privateStorage: the storage has public URLs, so private files must be kept elsewhere",
|
|
6414
|
+
500
|
|
6415
|
+
);
|
|
6416
|
+
return this.privateStorage;
|
|
6417
|
+
}
|
|
6418
|
+
/**
|
|
6419
|
+
* Copies a file and its resized copies to the other storage under their new names (`.private`
|
|
6420
|
+
* added or removed). Returns the fields to save, and `cleanUp` to delete the old copies once
|
|
6421
|
+
* the document points to the new ones.
|
|
6422
|
+
*/
|
|
6423
|
+
async relocate(doc, toPrivate) {
|
|
6424
|
+
const from = this.storageFor(doc.private === true);
|
|
6425
|
+
const to = this.storageFor(toPrivate);
|
|
6426
|
+
const contentType = String(doc.mimeType ?? "application/octet-stream");
|
|
6427
|
+
const filename = withPrivacy(String(doc.filename), toPrivate);
|
|
6428
|
+
const old = [String(doc.filename)];
|
|
6429
|
+
const copy = async (source, target) => {
|
|
6430
|
+
const file = await from.get(source);
|
|
6431
|
+
if (file) await to.put(target, file.body, { contentType });
|
|
6432
|
+
};
|
|
6433
|
+
await copy(String(doc.filename), filename);
|
|
6434
|
+
const sizes = {};
|
|
6435
|
+
for (const [name, size] of Object.entries(
|
|
6436
|
+
doc.sizes ?? {}
|
|
6437
|
+
)) {
|
|
6438
|
+
const key2 = sizeKey(filename, name);
|
|
6439
|
+
await copy(String(size.filename), key2);
|
|
6440
|
+
old.push(String(size.filename));
|
|
6441
|
+
sizes[name] = { ...size, filename: key2 };
|
|
6442
|
+
}
|
|
6443
|
+
return {
|
|
6444
|
+
patch: { filename, sizes, private: toPrivate },
|
|
6445
|
+
cleanUp: async () => {
|
|
6446
|
+
for (const key2 of old) await from.delete(key2).catch(() => {
|
|
6447
|
+
});
|
|
6448
|
+
}
|
|
6449
|
+
};
|
|
6450
|
+
}
|
|
6451
|
+
/** A folder's name, parent and permissions; private only with somewhere private to keep files. */
|
|
6452
|
+
async validateFolder(data, self) {
|
|
6453
|
+
await this.folders.validate(data, self);
|
|
6454
|
+
if (data.private === true && !this.privateStorage)
|
|
6455
|
+
throw new ValidationError(MEDIA_FOLDERS, [
|
|
6456
|
+
{
|
|
6457
|
+
field: "private",
|
|
6458
|
+
message: "needs upload.privateStorage: the storage gives files public URLs, so private files must be kept elsewhere"
|
|
6459
|
+
}
|
|
6460
|
+
]);
|
|
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
|
+
}
|
|
6484
|
+
/** After a folder became private or public (or moved): its files follow, in every subfolder. */
|
|
6485
|
+
async settlePrivacy(folder) {
|
|
6486
|
+
for (const f of await this.folders.subtree(folder))
|
|
6487
|
+
await this.settleFiles({ folder: { equals: f.id } }, f.private);
|
|
6488
|
+
}
|
|
6489
|
+
/** Files at the top level are public. */
|
|
6490
|
+
async settleTopLevel() {
|
|
6491
|
+
await this.settleFiles({ folder: { exists: false } }, false);
|
|
6492
|
+
}
|
|
6493
|
+
async settleFiles(where, isPrivate) {
|
|
6494
|
+
const found = await this.db.find({
|
|
6495
|
+
collection: MEDIA,
|
|
6496
|
+
where: andWhere(where, isPrivate ? PUBLIC_FILES : { private: { equals: true } }),
|
|
6497
|
+
sort: [],
|
|
6498
|
+
limit: 0,
|
|
6499
|
+
page: 1
|
|
6500
|
+
});
|
|
6501
|
+
for (const doc of found.docs) {
|
|
6502
|
+
const moved = await this.relocate(doc, isPrivate);
|
|
6503
|
+
const { id, ...rest } = doc;
|
|
6504
|
+
await this.db.update({ collection: MEDIA, id, data: { ...rest, ...moved.patch } });
|
|
6505
|
+
await moved.cleanUp();
|
|
6506
|
+
}
|
|
6001
6507
|
}
|
|
6002
6508
|
async update(collection2, id, data, options = {}) {
|
|
6003
6509
|
return await this.updateDocument(collection2, id, asObject(data, collection2), options);
|
|
@@ -6279,13 +6785,35 @@ var EasyCMS = class {
|
|
|
6279
6785
|
}
|
|
6280
6786
|
if (config.slug === USERS) await this.guardLastAdmin(parsed, existing, prepared);
|
|
6281
6787
|
if (password !== void 0) prepared.passwordHash = await hashPassword(password);
|
|
6282
|
-
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
|
+
}
|
|
6798
|
+
let moved;
|
|
6799
|
+
if (collection2 === MEDIA && this.folders.enabled) {
|
|
6800
|
+
const toPrivate = await this.folders.isPrivate(folderOf(MEDIA, prepared));
|
|
6801
|
+
if (toPrivate !== (existing.private === true)) {
|
|
6802
|
+
moved = await this.relocate(existing, toPrivate);
|
|
6803
|
+
Object.assign(prepared, moved.patch);
|
|
6804
|
+
}
|
|
6805
|
+
}
|
|
6283
6806
|
const doc = await this.db.update({
|
|
6284
6807
|
collection: collection2,
|
|
6285
6808
|
id: parsed,
|
|
6286
6809
|
data: { ...prepared, createdAt: existing.createdAt, updatedAt: now }
|
|
6287
6810
|
});
|
|
6288
|
-
if (
|
|
6811
|
+
if (moved) await moved.cleanUp();
|
|
6812
|
+
if (collection2 === MEDIA_FOLDERS) {
|
|
6813
|
+
this.folders.invalidate();
|
|
6814
|
+
if (prepared.private !== existing.private || String(prepared.parent ?? null) !== String(existing.parent ?? null))
|
|
6815
|
+
await this.settlePrivacy(parsed);
|
|
6816
|
+
}
|
|
6289
6817
|
if (!live) await this.saveVersion(config, collectionParent(collection2), parsed, doc, guard);
|
|
6290
6818
|
if (password !== void 0) await this.auth.revokeSessions(parsed);
|
|
6291
6819
|
await this.notify(config.hooks?.afterChange, "afterChange", config.slug, {
|
|
@@ -6328,9 +6856,17 @@ var EasyCMS = class {
|
|
|
6328
6856
|
await this.auth.revokeSessions(parsed);
|
|
6329
6857
|
await this.auth.sso.forget(parsed);
|
|
6330
6858
|
}
|
|
6331
|
-
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
|
+
}
|
|
6332
6863
|
await this.db.delete({ collection: collection2, id: parsed });
|
|
6333
|
-
if (collection2 === MEDIA_FOLDERS)
|
|
6864
|
+
if (collection2 === MEDIA_FOLDERS) {
|
|
6865
|
+
this.folders.invalidate();
|
|
6866
|
+
const parent = existing.parent ?? null;
|
|
6867
|
+
if (parent !== null) await this.settlePrivacy(parent);
|
|
6868
|
+
else await this.settleTopLevel();
|
|
6869
|
+
}
|
|
6334
6870
|
if (versionLimit(config)) await this.versions.deleteAll(collectionParent(collection2), parsed);
|
|
6335
6871
|
if (config.schedule) {
|
|
6336
6872
|
for (const job of await this.pendingJobs(collection2, parsed))
|
|
@@ -7074,7 +7610,7 @@ var EasyCMS = class {
|
|
|
7074
7610
|
prepared
|
|
7075
7611
|
);
|
|
7076
7612
|
if (password !== void 0) prepared.passwordHash = await hashPassword(password);
|
|
7077
|
-
if (collection2 === MEDIA_FOLDERS) await this.
|
|
7613
|
+
if (collection2 === MEDIA_FOLDERS) await this.validateFolder(prepared, void 0);
|
|
7078
7614
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
7079
7615
|
const doc = await this.db.create({
|
|
7080
7616
|
collection: collection2,
|
|
@@ -7234,7 +7770,7 @@ var EasyCMS = class {
|
|
|
7234
7770
|
}
|
|
7235
7771
|
}
|
|
7236
7772
|
/** Writes resized copies with sharp, when installed and configured. */
|
|
7237
|
-
async resizeImage(data, mimeType, filename, sizes, stored) {
|
|
7773
|
+
async resizeImage(data, mimeType, filename, sizes, stored, storage = this.storage) {
|
|
7238
7774
|
if (sizes.length === 0 || !["image/png", "image/jpeg", "image/webp", "image/avif"].includes(mimeType))
|
|
7239
7775
|
return {};
|
|
7240
7776
|
const sharp = await loadSharp();
|
|
@@ -7252,8 +7788,8 @@ var EasyCMS = class {
|
|
|
7252
7788
|
fit: size.fit ?? "cover",
|
|
7253
7789
|
withoutEnlargement: true
|
|
7254
7790
|
}).toBuffer({ resolveWithObject: true });
|
|
7255
|
-
const key2 = filename
|
|
7256
|
-
await
|
|
7791
|
+
const key2 = sizeKey(filename, size.name);
|
|
7792
|
+
await storage.put(key2, new Uint8Array(out), { contentType: mimeType });
|
|
7257
7793
|
stored.push(key2);
|
|
7258
7794
|
result[size.name] = {
|
|
7259
7795
|
filename: key2,
|
|
@@ -7421,6 +7957,14 @@ var EasyCMS = class {
|
|
|
7421
7957
|
message: images ? `must be an image (${doc.filename ?? ref.id} is ${type || "not one"})` : `must be ${ref.mimeTypes.join(", ")} (${doc.filename ?? ref.id} is ${type || "unknown"})`
|
|
7422
7958
|
});
|
|
7423
7959
|
}
|
|
7960
|
+
if (ref.folderOnly && this.folders.enabled && !await this.folders.inKeyed(
|
|
7961
|
+
ref.folderOnly,
|
|
7962
|
+
doc.folder ?? null
|
|
7963
|
+
))
|
|
7964
|
+
errors.push({
|
|
7965
|
+
field: ref.field,
|
|
7966
|
+
message: `must be a file from the "${ref.folderOnly}" folder (${doc.filename ?? ref.id} is not)`
|
|
7967
|
+
});
|
|
7424
7968
|
}
|
|
7425
7969
|
}
|
|
7426
7970
|
return errors;
|
|
@@ -7487,6 +8031,19 @@ function folderOf(collection2, data) {
|
|
|
7487
8031
|
const value = data[collection2 === MEDIA ? "folder" : "parent"];
|
|
7488
8032
|
return value === void 0 || value === "" ? null : value;
|
|
7489
8033
|
}
|
|
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;
|
|
8039
|
+
function durationSeconds(value) {
|
|
8040
|
+
const match = typeof value === "string" ? /^(\d+)\s*([smhd])$/.exec(value.trim()) : null;
|
|
8041
|
+
const seconds = typeof value === "number" ? value : match ? Number(match[1]) * { s: 1, m: 60, h: 3600, d: 86400 }[match[2]] : Number.NaN;
|
|
8042
|
+
if (!Number.isFinite(seconds) || seconds <= 0)
|
|
8043
|
+
throw new QueryError(`expiresIn must be seconds or like '30m', '1h', '7d' (got ${value})`);
|
|
8044
|
+
if (seconds > MAX_LINK) throw new QueryError("expiresIn can be at most 7 days");
|
|
8045
|
+
return Math.floor(seconds);
|
|
8046
|
+
}
|
|
7490
8047
|
|
|
7491
8048
|
// src/rest/admin-modules.ts
|
|
7492
8049
|
import { createHash as createHash3 } from "crypto";
|
|
@@ -7735,7 +8292,7 @@ function componentRef(component) {
|
|
|
7735
8292
|
async function allowed(access, user) {
|
|
7736
8293
|
return await evaluateAccess(access, { user }) !== false;
|
|
7737
8294
|
}
|
|
7738
|
-
async function serializeFields(fields, checks, localized) {
|
|
8295
|
+
async function serializeFields(fields, checks, localized, folderId) {
|
|
7739
8296
|
const { update: update2 } = checks;
|
|
7740
8297
|
const out = [];
|
|
7741
8298
|
for (const field of fields) {
|
|
@@ -7788,11 +8345,13 @@ async function serializeFields(fields, checks, localized) {
|
|
|
7788
8345
|
if (field.minRows !== void 0) f.minRows = field.minRows;
|
|
7789
8346
|
if (field.maxRows !== void 0) f.maxRows = field.maxRows;
|
|
7790
8347
|
if (field.mimeTypes) f.mimeTypes = expandMimeTypes(field.mimeTypes);
|
|
8348
|
+
if (field.folder && folderId)
|
|
8349
|
+
f.folder = { id: await folderId(field.folder), only: field.folderOnly === true };
|
|
7791
8350
|
break;
|
|
7792
8351
|
case "array":
|
|
7793
8352
|
if (field.minRows !== void 0) f.minRows = field.minRows;
|
|
7794
8353
|
if (field.maxRows !== void 0) f.maxRows = field.maxRows;
|
|
7795
|
-
f.fields = await serializeFields(field.fields, checks, localized);
|
|
8354
|
+
f.fields = await serializeFields(field.fields, checks, localized, folderId);
|
|
7796
8355
|
break;
|
|
7797
8356
|
case "blocks":
|
|
7798
8357
|
if (field.minRows !== void 0) f.minRows = field.minRows;
|
|
@@ -7801,12 +8360,12 @@ async function serializeFields(fields, checks, localized) {
|
|
|
7801
8360
|
field.blocks.map(async (block) => ({
|
|
7802
8361
|
slug: block.slug,
|
|
7803
8362
|
...block.labels ? { labels: block.labels } : {},
|
|
7804
|
-
fields: await serializeFields(block.fields, checks, localized)
|
|
8363
|
+
fields: await serializeFields(block.fields, checks, localized, folderId)
|
|
7805
8364
|
}))
|
|
7806
8365
|
);
|
|
7807
8366
|
break;
|
|
7808
8367
|
case "group":
|
|
7809
|
-
f.fields = await serializeFields(field.fields, checks, localized);
|
|
8368
|
+
f.fields = await serializeFields(field.fields, checks, localized, folderId);
|
|
7810
8369
|
break;
|
|
7811
8370
|
}
|
|
7812
8371
|
out.push(f);
|
|
@@ -7825,7 +8384,12 @@ async function may(cms, access, user, target, operation) {
|
|
|
7825
8384
|
}
|
|
7826
8385
|
async function collection(cms, config, user, localized, roles) {
|
|
7827
8386
|
const target = { collection: config.slug };
|
|
7828
|
-
const fields = await serializeFields(
|
|
8387
|
+
const fields = await serializeFields(
|
|
8388
|
+
config.fields,
|
|
8389
|
+
await checkers(cms, user),
|
|
8390
|
+
localized,
|
|
8391
|
+
folderIds(cms)
|
|
8392
|
+
);
|
|
7829
8393
|
if (config.slug === USERS && cms.roles.enabled) {
|
|
7830
8394
|
const role = fields.find((f) => f.name === "role");
|
|
7831
8395
|
if (role) {
|
|
@@ -7870,7 +8434,7 @@ async function global(cms, config, user, localized) {
|
|
|
7870
8434
|
versions: Boolean(config.versions),
|
|
7871
8435
|
preview: typeof config.preview === "function",
|
|
7872
8436
|
schedule: config.schedule === true,
|
|
7873
|
-
fields: await serializeFields(config.fields, checks, localized),
|
|
8437
|
+
fields: await serializeFields(config.fields, checks, localized, folderIds(cms)),
|
|
7874
8438
|
permissions: {
|
|
7875
8439
|
read: await may(cms, config.access?.read, user, target, "read"),
|
|
7876
8440
|
update: await may(cms, config.access?.update, user, target, "update"),
|
|
@@ -7915,7 +8479,13 @@ async function adminSchema(cms, user, origin) {
|
|
|
7915
8479
|
maxFileSize: cms.config.upload.maxFileSize,
|
|
7916
8480
|
mimeTypes: expandMimeTypes(cms.config.upload.mimeTypes)
|
|
7917
8481
|
},
|
|
7918
|
-
...cms.folders.enabled ? {
|
|
8482
|
+
...cms.folders.enabled ? {
|
|
8483
|
+
folders: {
|
|
8484
|
+
permissions: admin && cms.roles.enabled,
|
|
8485
|
+
// Admins can make folders private when there is somewhere private to keep files.
|
|
8486
|
+
private: admin && cms.privateStorage !== null
|
|
8487
|
+
}
|
|
8488
|
+
} : {},
|
|
7919
8489
|
views,
|
|
7920
8490
|
rbac: cms.roles.enabled,
|
|
7921
8491
|
providers: cms.auth.sso.providers(),
|
|
@@ -7962,9 +8532,12 @@ async function widgets(cms, user) {
|
|
|
7962
8532
|
}
|
|
7963
8533
|
return out;
|
|
7964
8534
|
}
|
|
8535
|
+
function folderIds(cms) {
|
|
8536
|
+
return cms.folders.enabled ? (key2) => cms.folders.keyed(key2) : void 0;
|
|
8537
|
+
}
|
|
7965
8538
|
|
|
7966
8539
|
// src/version.ts
|
|
7967
|
-
var VERSION = true ? "0.
|
|
8540
|
+
var VERSION = true ? "0.41.0" : "0.0.0-dev";
|
|
7968
8541
|
|
|
7969
8542
|
// src/rest/admin-status.ts
|
|
7970
8543
|
var DAY2 = 864e5;
|
|
@@ -8533,6 +9106,12 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
8533
9106
|
if (parsed === void 0) throw new HttpError("Not found", 404);
|
|
8534
9107
|
return { body: await cms.roles.owned(parsed) };
|
|
8535
9108
|
}
|
|
9109
|
+
if (first === "admin" && second === "media-usage" && segments.length === 2) {
|
|
9110
|
+
if (!ctx.user || ctx.user.apiKey) throw new UnauthorizedError();
|
|
9111
|
+
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
9112
|
+
const ids = (ctx.url.searchParams.get("ids") ?? "").split(",").map((id2) => parseId(id2)).filter((id2) => id2 !== void 0);
|
|
9113
|
+
return { body: { count: await cms.mediaUsage(ids) } };
|
|
9114
|
+
}
|
|
8536
9115
|
if (first === "admin" && second === "roles") {
|
|
8537
9116
|
if (!ctx.user) throw new UnauthorizedError();
|
|
8538
9117
|
if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
|
|
@@ -8679,6 +9258,34 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
8679
9258
|
if (method !== "GET" && method !== "HEAD") throw methodNotAllowed(ctx, "GET, HEAD");
|
|
8680
9259
|
return { body: await serveFile(cms, third, method === "HEAD") };
|
|
8681
9260
|
}
|
|
9261
|
+
if (first === MEDIA && second === "private" && third !== void 0 && segments.length === 3) {
|
|
9262
|
+
if (method !== "GET" && method !== "HEAD") throw methodNotAllowed(ctx, "GET, HEAD");
|
|
9263
|
+
return { body: await servePrivateFile(cms, ctx, third, method === "HEAD") };
|
|
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
|
+
}
|
|
8682
9289
|
if (first === MEDIA && second === void 0 && method === "POST") {
|
|
8683
9290
|
if ((ctx.request.headers.get("content-type") ?? "").toLowerCase().startsWith("application/json")) {
|
|
8684
9291
|
const { url, ...data2 } = await readJson(ctx.request);
|
|
@@ -8978,6 +9585,48 @@ async function serveFile(cms, key2, head) {
|
|
|
8978
9585
|
}
|
|
8979
9586
|
);
|
|
8980
9587
|
}
|
|
9588
|
+
async function servePrivateFile(cms, ctx, key2, head) {
|
|
9589
|
+
if (!PRIVATE_KEY.test(key2) || !cms.privateStorage) throw new HttpError("Not found", 404);
|
|
9590
|
+
const query = ctx.url.searchParams;
|
|
9591
|
+
const signed = cms.verifyMediaSignature(key2, query.get("expires"), query.get("signature"));
|
|
9592
|
+
if (!signed) {
|
|
9593
|
+
const token = /-([0-9a-f]{8})\.private[.-]/.exec(key2)?.[1];
|
|
9594
|
+
if (!ctx.user || !token) throw new HttpError("Not found", 404);
|
|
9595
|
+
const found = await cms.find(MEDIA, {
|
|
9596
|
+
where: { filename: { like: `-${token}.private.` } },
|
|
9597
|
+
limit: 5,
|
|
9598
|
+
depth: 0,
|
|
9599
|
+
user: ctx.user,
|
|
9600
|
+
overrideAccess: false
|
|
9601
|
+
});
|
|
9602
|
+
const owns = found.docs.some(
|
|
9603
|
+
(d) => d.filename === key2 || Object.values(d.sizes ?? {}).some(
|
|
9604
|
+
(s) => s.filename === key2
|
|
9605
|
+
)
|
|
9606
|
+
);
|
|
9607
|
+
if (!owns) throw new HttpError("Not found", 404);
|
|
9608
|
+
}
|
|
9609
|
+
const file = await cms.privateStorage.get(key2);
|
|
9610
|
+
if (!file) throw new HttpError("Not found", 404);
|
|
9611
|
+
const type = TYPE_BY_EXTENSION[key2.slice(key2.lastIndexOf(".") + 1)] ?? "application/octet-stream";
|
|
9612
|
+
return new Response(
|
|
9613
|
+
head ? null : file.body,
|
|
9614
|
+
{
|
|
9615
|
+
status: 200,
|
|
9616
|
+
headers: {
|
|
9617
|
+
"content-type": type,
|
|
9618
|
+
"content-length": String(file.size),
|
|
9619
|
+
// Not in shared caches; a short while in the browser.
|
|
9620
|
+
"cache-control": "private, max-age=300",
|
|
9621
|
+
"x-content-type-options": "nosniff",
|
|
9622
|
+
...type === "application/pdf" ? {} : {
|
|
9623
|
+
"content-security-policy": "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox"
|
|
9624
|
+
},
|
|
9625
|
+
"cross-origin-resource-policy": "cross-origin"
|
|
9626
|
+
}
|
|
9627
|
+
}
|
|
9628
|
+
);
|
|
9629
|
+
}
|
|
8981
9630
|
async function readUpload(request, maxFileSize) {
|
|
8982
9631
|
const type = request.headers.get("content-type") ?? "";
|
|
8983
9632
|
if (!type.toLowerCase().startsWith("multipart/form-data")) {
|