@easy-cms/core 0.38.0 → 0.40.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 +151 -9
- package/dist/index.js +635 -129
- 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 ?? []
|
|
@@ -917,11 +950,173 @@ var PayloadTooLargeError = class extends EasyCMSError {
|
|
|
917
950
|
}
|
|
918
951
|
};
|
|
919
952
|
|
|
953
|
+
// src/media.ts
|
|
954
|
+
var EXTENSIONS = {
|
|
955
|
+
"image/png": "png",
|
|
956
|
+
"image/jpeg": "jpg",
|
|
957
|
+
"image/gif": "gif",
|
|
958
|
+
"image/webp": "webp",
|
|
959
|
+
"image/avif": "avif",
|
|
960
|
+
"image/svg+xml": "svg",
|
|
961
|
+
"application/pdf": "pdf",
|
|
962
|
+
"video/mp4": "mp4",
|
|
963
|
+
"video/webm": "webm",
|
|
964
|
+
"video/quicktime": "mov",
|
|
965
|
+
"audio/mpeg": "mp3",
|
|
966
|
+
"audio/wav": "wav",
|
|
967
|
+
"audio/ogg": "ogg",
|
|
968
|
+
"audio/mp4": "m4a",
|
|
969
|
+
"text/plain": "txt",
|
|
970
|
+
"text/csv": "csv",
|
|
971
|
+
"application/zip": "zip",
|
|
972
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
|
|
973
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
|
|
974
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
|
|
975
|
+
"application/vnd.oasis.opendocument.text": "odt",
|
|
976
|
+
"application/vnd.oasis.opendocument.spreadsheet": "ods",
|
|
977
|
+
"application/vnd.oasis.opendocument.presentation": "odp"
|
|
978
|
+
};
|
|
979
|
+
var OFFICE = [
|
|
980
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
981
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
982
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
|
983
|
+
];
|
|
984
|
+
var MIME_GROUPS = {
|
|
985
|
+
office: OFFICE,
|
|
986
|
+
documents: [
|
|
987
|
+
"application/pdf",
|
|
988
|
+
...OFFICE,
|
|
989
|
+
"application/vnd.oasis.opendocument.text",
|
|
990
|
+
"application/vnd.oasis.opendocument.spreadsheet",
|
|
991
|
+
"application/vnd.oasis.opendocument.presentation",
|
|
992
|
+
"text/csv",
|
|
993
|
+
"text/plain"
|
|
994
|
+
],
|
|
995
|
+
archives: ["application/zip"]
|
|
996
|
+
};
|
|
997
|
+
function expandMimeTypes(patterns) {
|
|
998
|
+
return [...new Set(patterns.flatMap((p) => MIME_GROUPS[p.toLowerCase()] ?? [p]))];
|
|
999
|
+
}
|
|
1000
|
+
var startsWith = (data, bytes, offset = 0) => bytes.every((b, i) => data[offset + i] === b);
|
|
1001
|
+
var ascii = (data, start, end) => String.fromCharCode(...data.subarray(start, end));
|
|
1002
|
+
var contains = (data, text2) => Buffer.from(data.buffer, data.byteOffset, data.byteLength).includes(text2, 0, "latin1");
|
|
1003
|
+
function zipType(data) {
|
|
1004
|
+
if (ascii(data, 30, 38) === "mimetype") {
|
|
1005
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
1006
|
+
const start = 30 + view.getUint16(26, true) + view.getUint16(28, true);
|
|
1007
|
+
const type = ascii(data, start, start + Math.min(view.getUint32(18, true), 80));
|
|
1008
|
+
if (EXTENSIONS[type]?.startsWith("od")) return type;
|
|
1009
|
+
}
|
|
1010
|
+
if (contains(data, "word/document.xml")) return OFFICE[0];
|
|
1011
|
+
if (contains(data, "xl/workbook.xml")) return OFFICE[1];
|
|
1012
|
+
if (contains(data, "ppt/presentation.xml")) return OFFICE[2];
|
|
1013
|
+
return "application/zip";
|
|
1014
|
+
}
|
|
1015
|
+
function sniffMimeType(data, name = "") {
|
|
1016
|
+
if (startsWith(data, [137, 80, 78, 71, 13, 10, 26, 10])) return "image/png";
|
|
1017
|
+
if (startsWith(data, [255, 216, 255])) return "image/jpeg";
|
|
1018
|
+
if (ascii(data, 0, 6) === "GIF87a" || ascii(data, 0, 6) === "GIF89a") return "image/gif";
|
|
1019
|
+
if (ascii(data, 0, 4) === "RIFF" && ascii(data, 8, 12) === "WEBP") return "image/webp";
|
|
1020
|
+
if (ascii(data, 0, 4) === "RIFF" && ascii(data, 8, 12) === "WAVE") return "audio/wav";
|
|
1021
|
+
if (ascii(data, 4, 8) === "ftyp") {
|
|
1022
|
+
const brand = ascii(data, 8, 12);
|
|
1023
|
+
if (brand === "avif" || brand === "avis") return "image/avif";
|
|
1024
|
+
if (["isom", "iso2", "mp41", "mp42", "avc1", "M4V "].includes(brand)) return "video/mp4";
|
|
1025
|
+
if (brand === "M4A ") return "audio/mp4";
|
|
1026
|
+
if (brand === "qt ") return "video/quicktime";
|
|
1027
|
+
}
|
|
1028
|
+
if (ascii(data, 0, 5) === "%PDF-") return "application/pdf";
|
|
1029
|
+
if (startsWith(data, [80, 75, 3, 4])) return zipType(data);
|
|
1030
|
+
if (ascii(data, 0, 4) === "OggS") return "audio/ogg";
|
|
1031
|
+
if (startsWith(data, [26, 69, 223, 163]) && ascii(data, 0, 64).includes("webm"))
|
|
1032
|
+
return "video/webm";
|
|
1033
|
+
if (ascii(data, 0, 3) === "ID3" || data[0] === 255 && ((data[1] ?? 0) & 230) === 226)
|
|
1034
|
+
return "audio/mpeg";
|
|
1035
|
+
const head = data.subarray(0, 4096);
|
|
1036
|
+
if (head.includes(0)) return void 0;
|
|
1037
|
+
let text2;
|
|
1038
|
+
try {
|
|
1039
|
+
text2 = new TextDecoder("utf-8", { fatal: true }).decode(head);
|
|
1040
|
+
} catch {
|
|
1041
|
+
return void 0;
|
|
1042
|
+
}
|
|
1043
|
+
const trimmed = text2.replace(/^/, "").trimStart();
|
|
1044
|
+
if (/^(<\?xml[^>]*>\s*)?(<!--[\s\S]*?-->\s*)*(<!DOCTYPE svg[^>]*>\s*)?<svg[\s>]/i.test(trimmed))
|
|
1045
|
+
return "image/svg+xml";
|
|
1046
|
+
return /\.csv$/i.test(name) ? "text/csv" : "text/plain";
|
|
1047
|
+
}
|
|
1048
|
+
function mimeAllowed(type, allowed2) {
|
|
1049
|
+
return expandMimeTypes(allowed2).some(
|
|
1050
|
+
(pattern) => pattern.endsWith("/*") ? type.startsWith(pattern.slice(0, -1)) : pattern === type
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
function imageDimensions(data, type) {
|
|
1054
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
1055
|
+
try {
|
|
1056
|
+
switch (type) {
|
|
1057
|
+
case "image/png":
|
|
1058
|
+
return { width: view.getUint32(16), height: view.getUint32(20) };
|
|
1059
|
+
case "image/gif":
|
|
1060
|
+
return { width: view.getUint16(6, true), height: view.getUint16(8, true) };
|
|
1061
|
+
case "image/webp": {
|
|
1062
|
+
const chunk = ascii(data, 12, 16);
|
|
1063
|
+
if (chunk === "VP8 ")
|
|
1064
|
+
return {
|
|
1065
|
+
width: view.getUint16(26, true) & 16383,
|
|
1066
|
+
height: view.getUint16(28, true) & 16383
|
|
1067
|
+
};
|
|
1068
|
+
if (chunk === "VP8L") {
|
|
1069
|
+
const bits = view.getUint32(21, true);
|
|
1070
|
+
return { width: (bits & 16383) + 1, height: (bits >> 14 & 16383) + 1 };
|
|
1071
|
+
}
|
|
1072
|
+
if (chunk === "VP8X") {
|
|
1073
|
+
const w = view.getUint16(24, true) | view.getUint8(26) << 16;
|
|
1074
|
+
const h = view.getUint16(27, true) | view.getUint8(29) << 16;
|
|
1075
|
+
return { width: w + 1, height: h + 1 };
|
|
1076
|
+
}
|
|
1077
|
+
return void 0;
|
|
1078
|
+
}
|
|
1079
|
+
case "image/jpeg": {
|
|
1080
|
+
let offset = 2;
|
|
1081
|
+
while (offset + 9 < data.length) {
|
|
1082
|
+
if (data[offset] !== 255) return void 0;
|
|
1083
|
+
const marker = data[offset + 1];
|
|
1084
|
+
const length = view.getUint16(offset + 2);
|
|
1085
|
+
if (marker >= 192 && marker <= 207 && ![196, 200, 204].includes(marker)) {
|
|
1086
|
+
return { width: view.getUint16(offset + 7), height: view.getUint16(offset + 5) };
|
|
1087
|
+
}
|
|
1088
|
+
offset += 2 + length;
|
|
1089
|
+
}
|
|
1090
|
+
return void 0;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
} catch {
|
|
1094
|
+
return void 0;
|
|
1095
|
+
}
|
|
1096
|
+
return void 0;
|
|
1097
|
+
}
|
|
1098
|
+
function storageKey(originalName, type, random2) {
|
|
1099
|
+
const name = originalName.split(/[\\/]/).pop() ?? "";
|
|
1100
|
+
const base = name.replace(/(?<=.)\.[^.]*$/, "").normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{M}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "file";
|
|
1101
|
+
return `${base}-${random2}.${EXTENSIONS[type] ?? "bin"}`;
|
|
1102
|
+
}
|
|
1103
|
+
function isPrivateKey(key2) {
|
|
1104
|
+
return /\.private[.-]/.test(key2);
|
|
1105
|
+
}
|
|
1106
|
+
function withPrivacy(key2, isPrivate) {
|
|
1107
|
+
const plain = key2.replace(/\.private(?=\.[^.]+$)/, "");
|
|
1108
|
+
return isPrivate ? plain.replace(/\.([^.]+)$/, ".private.$1") : plain;
|
|
1109
|
+
}
|
|
1110
|
+
function sizeKey(filename, size) {
|
|
1111
|
+
return filename.replace(/\.([^.]+)$/, `-${size}.$1`);
|
|
1112
|
+
}
|
|
1113
|
+
var PRIVATE_KEY = /^[\p{L}\p{M}\p{N}-]+\.private(-[\p{L}\p{N}_-]+)?\.[a-z0-9]+$/u;
|
|
1114
|
+
|
|
920
1115
|
// src/fields.ts
|
|
921
1116
|
var isHasMany = (field) => (field.type === "select" || field.type === "relationship" || field.type === "upload") && field.hasMany === true;
|
|
922
1117
|
function mimeAllowedBy(mimeType, patterns) {
|
|
923
1118
|
const type = mimeType.toLowerCase();
|
|
924
|
-
return patterns.some((pattern) => {
|
|
1119
|
+
return expandMimeTypes(patterns).some((pattern) => {
|
|
925
1120
|
const p = pattern.toLowerCase();
|
|
926
1121
|
return p.endsWith("/*") ? type.startsWith(p.slice(0, -1)) : type === p;
|
|
927
1122
|
});
|
|
@@ -2655,7 +2850,8 @@ async function normalizeValue(field, raw, path, options, fail, errors, reference
|
|
|
2655
2850
|
collection: target,
|
|
2656
2851
|
id,
|
|
2657
2852
|
...field.type === "relationship" && field.filterOptions ? { filterOptions: field.filterOptions } : {},
|
|
2658
|
-
...field.type === "upload" && field.mimeTypes ? { mimeTypes: field.mimeTypes } : {}
|
|
2853
|
+
...field.type === "upload" && field.mimeTypes ? { mimeTypes: field.mimeTypes } : {},
|
|
2854
|
+
...field.type === "upload" && field.folder && field.folderOnly ? { folderOnly: field.folder } : {}
|
|
2659
2855
|
});
|
|
2660
2856
|
}
|
|
2661
2857
|
return many ? ids : ids[0];
|
|
@@ -4352,6 +4548,25 @@ function validateCors(cors, add) {
|
|
|
4352
4548
|
}
|
|
4353
4549
|
function validateUpload(config, add) {
|
|
4354
4550
|
const upload = config.upload;
|
|
4551
|
+
if (upload?.folders !== true) {
|
|
4552
|
+
const walk = (fields, path) => {
|
|
4553
|
+
for (const [i, field] of fields.entries()) {
|
|
4554
|
+
const at = `${path}.fields[${i}]`;
|
|
4555
|
+
if (field.type === "upload" && field.folder !== void 0)
|
|
4556
|
+
add(`${at}.folder`, "needs upload.folders: true", "upload: { folders: true }");
|
|
4557
|
+
if ((field.type === "group" || field.type === "array") && Array.isArray(field.fields))
|
|
4558
|
+
walk(field.fields, at);
|
|
4559
|
+
if (field.type === "blocks" && Array.isArray(field.blocks)) {
|
|
4560
|
+
for (const [b, block] of field.blocks.entries())
|
|
4561
|
+
if (Array.isArray(block?.fields)) walk(block.fields, `${at}.blocks[${b}]`);
|
|
4562
|
+
}
|
|
4563
|
+
}
|
|
4564
|
+
};
|
|
4565
|
+
for (const [i, c] of (config.collections ?? []).entries())
|
|
4566
|
+
if (Array.isArray(c?.fields)) walk(c.fields, `collections[${i}]`);
|
|
4567
|
+
for (const [i, g] of (config.globals ?? []).entries())
|
|
4568
|
+
if (Array.isArray(g?.fields)) walk(g.fields, `globals[${i}]`);
|
|
4569
|
+
}
|
|
4355
4570
|
if (upload === void 0) return;
|
|
4356
4571
|
if (upload.maxFileSize !== void 0 && !(Number.isInteger(upload.maxFileSize) && upload.maxFileSize > 0)) {
|
|
4357
4572
|
add("upload.maxFileSize", "must be a positive integer (bytes)");
|
|
@@ -4359,8 +4574,18 @@ function validateUpload(config, add) {
|
|
|
4359
4574
|
if (upload.dir !== void 0 && (typeof upload.dir !== "string" || upload.dir.length === 0)) {
|
|
4360
4575
|
add("upload.dir", "must be a non-empty string");
|
|
4361
4576
|
}
|
|
4577
|
+
const mimeTypes = upload.mimeTypes;
|
|
4578
|
+
if (mimeTypes !== void 0 && (!Array.isArray(mimeTypes) || mimeTypes.length === 0 || !mimeTypes.every((t) => typeof t === "string" && MIME_PATTERN.test(t))))
|
|
4579
|
+
add(
|
|
4580
|
+
"upload.mimeTypes",
|
|
4581
|
+
"must be a list of MIME types or groups (documents, office, archives)",
|
|
4582
|
+
"e.g. mimeTypes: ['image/*', 'documents']"
|
|
4583
|
+
);
|
|
4362
4584
|
if (upload.folders !== void 0 && typeof upload.folders !== "boolean")
|
|
4363
4585
|
add("upload.folders", "must be true or false");
|
|
4586
|
+
const privateStorage = upload.privateStorage;
|
|
4587
|
+
if (privateStorage !== void 0 && (typeof privateStorage !== "object" || privateStorage === null || typeof privateStorage.get !== "function"))
|
|
4588
|
+
add("upload.privateStorage", "must be a storage adapter", "e.g. s3Storage({ \u2026 })");
|
|
4364
4589
|
const fromURL = upload.fromURL;
|
|
4365
4590
|
if (fromURL === void 0) return;
|
|
4366
4591
|
if (typeof fromURL !== "object" || fromURL === null) {
|
|
@@ -4386,6 +4611,8 @@ function validateUpload(config, add) {
|
|
|
4386
4611
|
if (allowPrivate !== void 0 && typeof allowPrivate !== "boolean")
|
|
4387
4612
|
add("upload.fromURL.allowPrivate", "must be true or false");
|
|
4388
4613
|
}
|
|
4614
|
+
var FOLDER_KEY = /^[a-z0-9][a-z0-9_-]{0,59}$/;
|
|
4615
|
+
var MIME_PATTERN = /^([\w.+-]+\/(\*|[\w.+-]+)|documents|office|archives)$/i;
|
|
4389
4616
|
var HOST_PATTERN = /^(\*|(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*)$/i;
|
|
4390
4617
|
function validateAudit(config, add) {
|
|
4391
4618
|
const audit = config.audit;
|
|
@@ -4633,10 +4860,22 @@ function validateField(field, path, siblings, collectionSlugs, add) {
|
|
|
4633
4860
|
break;
|
|
4634
4861
|
case "upload":
|
|
4635
4862
|
validateManyRange(field, path, add);
|
|
4863
|
+
if (field.folder !== void 0 && !FOLDER_KEY.test(String(field.folder)))
|
|
4864
|
+
add(
|
|
4865
|
+
`${path}.folder`,
|
|
4866
|
+
'must be a folder key: lowercase letters, digits, "-" or "_"',
|
|
4867
|
+
"e.g. folder: 'banners'"
|
|
4868
|
+
);
|
|
4869
|
+
if (field.folderOnly !== void 0 && field.folder === void 0)
|
|
4870
|
+
add(`${path}.folderOnly`, "needs folder");
|
|
4636
4871
|
if (field.mimeTypes !== void 0) {
|
|
4637
4872
|
const types = field.mimeTypes;
|
|
4638
|
-
if (!Array.isArray(types) || types.length === 0 || !types.every((t) => typeof t === "string" &&
|
|
4639
|
-
add(
|
|
4873
|
+
if (!Array.isArray(types) || types.length === 0 || !types.every((t) => typeof t === "string" && MIME_PATTERN.test(t))) {
|
|
4874
|
+
add(
|
|
4875
|
+
`${path}.mimeTypes`,
|
|
4876
|
+
"must be a list of MIME types or groups (documents, office, archives)",
|
|
4877
|
+
"e.g. mimeTypes: ['image/*', 'documents']"
|
|
4878
|
+
);
|
|
4640
4879
|
}
|
|
4641
4880
|
}
|
|
4642
4881
|
break;
|
|
@@ -4907,7 +5146,8 @@ async function resolveConfig(input) {
|
|
|
4907
5146
|
imageSizes: config.upload?.imageSizes ?? [],
|
|
4908
5147
|
folders: config.upload?.folders === true,
|
|
4909
5148
|
...config.upload?.storage ? { storage: config.upload.storage } : {},
|
|
4910
|
-
...config.upload?.fromURL ? { fromURL: config.upload.fromURL } : {}
|
|
5149
|
+
...config.upload?.fromURL ? { fromURL: config.upload.fromURL } : {},
|
|
5150
|
+
...config.upload?.privateStorage ? { privateStorage: config.upload.privateStorage } : {}
|
|
4911
5151
|
},
|
|
4912
5152
|
auth: {
|
|
4913
5153
|
roles: config.auth?.roles ?? DEFAULT_ROLES,
|
|
@@ -5010,7 +5250,7 @@ async function loadConfig(options = {}) {
|
|
|
5010
5250
|
}
|
|
5011
5251
|
|
|
5012
5252
|
// src/local-api.ts
|
|
5013
|
-
import { randomBytes as randomBytes5 } from "crypto";
|
|
5253
|
+
import { createHmac as createHmac5, randomBytes as randomBytes5, timingSafeEqual as timingSafeEqual4 } from "crypto";
|
|
5014
5254
|
|
|
5015
5255
|
// src/localization.ts
|
|
5016
5256
|
var ALL_LOCALES = "all";
|
|
@@ -5119,104 +5359,18 @@ var silentLogger = { info() {
|
|
|
5119
5359
|
}, error() {
|
|
5120
5360
|
} };
|
|
5121
5361
|
|
|
5122
|
-
// src/media.ts
|
|
5123
|
-
var EXTENSIONS = {
|
|
5124
|
-
"image/png": "png",
|
|
5125
|
-
"image/jpeg": "jpg",
|
|
5126
|
-
"image/gif": "gif",
|
|
5127
|
-
"image/webp": "webp",
|
|
5128
|
-
"image/avif": "avif",
|
|
5129
|
-
"image/svg+xml": "svg",
|
|
5130
|
-
"application/pdf": "pdf",
|
|
5131
|
-
"video/mp4": "mp4",
|
|
5132
|
-
"text/plain": "txt"
|
|
5133
|
-
};
|
|
5134
|
-
var startsWith = (data, bytes, offset = 0) => bytes.every((b, i) => data[offset + i] === b);
|
|
5135
|
-
var ascii = (data, start, end) => String.fromCharCode(...data.subarray(start, end));
|
|
5136
|
-
function sniffMimeType(data) {
|
|
5137
|
-
if (startsWith(data, [137, 80, 78, 71, 13, 10, 26, 10])) return "image/png";
|
|
5138
|
-
if (startsWith(data, [255, 216, 255])) return "image/jpeg";
|
|
5139
|
-
if (ascii(data, 0, 6) === "GIF87a" || ascii(data, 0, 6) === "GIF89a") return "image/gif";
|
|
5140
|
-
if (ascii(data, 0, 4) === "RIFF" && ascii(data, 8, 12) === "WEBP") return "image/webp";
|
|
5141
|
-
if (ascii(data, 4, 8) === "ftyp") {
|
|
5142
|
-
const brand = ascii(data, 8, 12);
|
|
5143
|
-
if (brand === "avif" || brand === "avis") return "image/avif";
|
|
5144
|
-
if (["isom", "iso2", "mp41", "mp42", "avc1", "M4V "].includes(brand)) return "video/mp4";
|
|
5145
|
-
}
|
|
5146
|
-
if (ascii(data, 0, 5) === "%PDF-") return "application/pdf";
|
|
5147
|
-
const head = data.subarray(0, 4096);
|
|
5148
|
-
if (head.includes(0)) return void 0;
|
|
5149
|
-
let text2;
|
|
5150
|
-
try {
|
|
5151
|
-
text2 = new TextDecoder("utf-8", { fatal: true }).decode(head);
|
|
5152
|
-
} catch {
|
|
5153
|
-
return void 0;
|
|
5154
|
-
}
|
|
5155
|
-
const trimmed = text2.replace(/^/, "").trimStart();
|
|
5156
|
-
if (/^(<\?xml[^>]*>\s*)?(<!--[\s\S]*?-->\s*)*(<!DOCTYPE svg[^>]*>\s*)?<svg[\s>]/i.test(trimmed))
|
|
5157
|
-
return "image/svg+xml";
|
|
5158
|
-
return "text/plain";
|
|
5159
|
-
}
|
|
5160
|
-
function mimeAllowed(type, allowed2) {
|
|
5161
|
-
return allowed2.some(
|
|
5162
|
-
(pattern) => pattern.endsWith("/*") ? type.startsWith(pattern.slice(0, -1)) : pattern === type
|
|
5163
|
-
);
|
|
5164
|
-
}
|
|
5165
|
-
function imageDimensions(data, type) {
|
|
5166
|
-
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
5167
|
-
try {
|
|
5168
|
-
switch (type) {
|
|
5169
|
-
case "image/png":
|
|
5170
|
-
return { width: view.getUint32(16), height: view.getUint32(20) };
|
|
5171
|
-
case "image/gif":
|
|
5172
|
-
return { width: view.getUint16(6, true), height: view.getUint16(8, true) };
|
|
5173
|
-
case "image/webp": {
|
|
5174
|
-
const chunk = ascii(data, 12, 16);
|
|
5175
|
-
if (chunk === "VP8 ")
|
|
5176
|
-
return {
|
|
5177
|
-
width: view.getUint16(26, true) & 16383,
|
|
5178
|
-
height: view.getUint16(28, true) & 16383
|
|
5179
|
-
};
|
|
5180
|
-
if (chunk === "VP8L") {
|
|
5181
|
-
const bits = view.getUint32(21, true);
|
|
5182
|
-
return { width: (bits & 16383) + 1, height: (bits >> 14 & 16383) + 1 };
|
|
5183
|
-
}
|
|
5184
|
-
if (chunk === "VP8X") {
|
|
5185
|
-
const w = view.getUint16(24, true) | view.getUint8(26) << 16;
|
|
5186
|
-
const h = view.getUint16(27, true) | view.getUint8(29) << 16;
|
|
5187
|
-
return { width: w + 1, height: h + 1 };
|
|
5188
|
-
}
|
|
5189
|
-
return void 0;
|
|
5190
|
-
}
|
|
5191
|
-
case "image/jpeg": {
|
|
5192
|
-
let offset = 2;
|
|
5193
|
-
while (offset + 9 < data.length) {
|
|
5194
|
-
if (data[offset] !== 255) return void 0;
|
|
5195
|
-
const marker = data[offset + 1];
|
|
5196
|
-
const length = view.getUint16(offset + 2);
|
|
5197
|
-
if (marker >= 192 && marker <= 207 && ![196, 200, 204].includes(marker)) {
|
|
5198
|
-
return { width: view.getUint16(offset + 7), height: view.getUint16(offset + 5) };
|
|
5199
|
-
}
|
|
5200
|
-
offset += 2 + length;
|
|
5201
|
-
}
|
|
5202
|
-
return void 0;
|
|
5203
|
-
}
|
|
5204
|
-
}
|
|
5205
|
-
} catch {
|
|
5206
|
-
return void 0;
|
|
5207
|
-
}
|
|
5208
|
-
return void 0;
|
|
5209
|
-
}
|
|
5210
|
-
function storageKey(originalName, type, random2) {
|
|
5211
|
-
const name = originalName.split(/[\\/]/).pop() ?? "";
|
|
5212
|
-
const base = name.replace(/(?<=.)\.[^.]*$/, "").normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{M}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "file";
|
|
5213
|
-
return `${base}-${random2}.${EXTENSIONS[type] ?? "bin"}`;
|
|
5214
|
-
}
|
|
5215
|
-
|
|
5216
5362
|
// src/media-folders.ts
|
|
5217
5363
|
var FOLDER_LEVELS = ["view", "edit", "manage"];
|
|
5218
5364
|
var RANK = { none: 0, view: 1, edit: 2, manage: 3 };
|
|
5219
5365
|
var TTL2 = 1e4;
|
|
5366
|
+
var PUBLIC_FILES = {
|
|
5367
|
+
or: [{ private: { equals: false } }, { private: { exists: false } }]
|
|
5368
|
+
};
|
|
5369
|
+
function keyFolders(user) {
|
|
5370
|
+
const key2 = user?.apiKey;
|
|
5371
|
+
const folders = key2?.permissions?.folders;
|
|
5372
|
+
return folders && folders.length > 0 ? folders : void 0;
|
|
5373
|
+
}
|
|
5220
5374
|
function needed(collection2, operation) {
|
|
5221
5375
|
if (operation === "read") return "view";
|
|
5222
5376
|
return collection2 === MEDIA ? "edit" : "manage";
|
|
@@ -5249,6 +5403,8 @@ var MediaFolders = class {
|
|
|
5249
5403
|
id: doc.id,
|
|
5250
5404
|
parent: doc.parent ?? null,
|
|
5251
5405
|
name: String(doc.name ?? ""),
|
|
5406
|
+
key: typeof doc.key === "string" && doc.key ? doc.key : null,
|
|
5407
|
+
private: doc.private === true,
|
|
5252
5408
|
permissions: permissions && typeof permissions === "object" && !Array.isArray(permissions) ? permissions : null
|
|
5253
5409
|
});
|
|
5254
5410
|
}
|
|
@@ -5269,6 +5425,83 @@ var MediaFolders = class {
|
|
|
5269
5425
|
}
|
|
5270
5426
|
return "manage";
|
|
5271
5427
|
}
|
|
5428
|
+
/** Whether files in a folder (`null`: the top level) are private: it or a folder above is. */
|
|
5429
|
+
privateIn(rows, id) {
|
|
5430
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5431
|
+
let current = id === null ? void 0 : rows.get(String(id));
|
|
5432
|
+
while (current && !seen.has(String(current.id))) {
|
|
5433
|
+
if (current.private) return true;
|
|
5434
|
+
seen.add(String(current.id));
|
|
5435
|
+
current = current.parent === null ? void 0 : rows.get(String(current.parent));
|
|
5436
|
+
}
|
|
5437
|
+
return false;
|
|
5438
|
+
}
|
|
5439
|
+
/** Whether files put in this folder are private. */
|
|
5440
|
+
async isPrivate(id) {
|
|
5441
|
+
if (!this.enabled || id === null) return false;
|
|
5442
|
+
return this.privateIn(await this.rows(), id);
|
|
5443
|
+
}
|
|
5444
|
+
/** The folder and every folder inside it. */
|
|
5445
|
+
within(rows, ids) {
|
|
5446
|
+
const out = new Set(ids.map(String));
|
|
5447
|
+
let grew = true;
|
|
5448
|
+
while (grew) {
|
|
5449
|
+
grew = false;
|
|
5450
|
+
for (const row of rows.values())
|
|
5451
|
+
if (row.parent !== null && out.has(String(row.parent)) && !out.has(String(row.id))) {
|
|
5452
|
+
out.add(String(row.id));
|
|
5453
|
+
grew = true;
|
|
5454
|
+
}
|
|
5455
|
+
}
|
|
5456
|
+
return [...rows.values()].filter((r) => out.has(String(r.id))).map((r) => r.id);
|
|
5457
|
+
}
|
|
5458
|
+
/** Folders whose privacy follows this one: it and those inside, with their privacy now. */
|
|
5459
|
+
async subtree(id) {
|
|
5460
|
+
const rows = await this.fresh();
|
|
5461
|
+
return this.within(rows, [id]).map((f) => ({ id: f, private: this.privateIn(rows, f) }));
|
|
5462
|
+
}
|
|
5463
|
+
/**
|
|
5464
|
+
* The folder with this key (`folder: 'banners'` on upload fields), made at the top level when
|
|
5465
|
+
* there is none yet.
|
|
5466
|
+
*/
|
|
5467
|
+
async keyed(key2) {
|
|
5468
|
+
const find = async () => [...(await this.rows()).values()].find((r) => r.key === key2);
|
|
5469
|
+
let found = await find();
|
|
5470
|
+
if (!found) {
|
|
5471
|
+
this.invalidate();
|
|
5472
|
+
found = await find();
|
|
5473
|
+
}
|
|
5474
|
+
if (found) return found.id;
|
|
5475
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
5476
|
+
try {
|
|
5477
|
+
const doc = await this.db.create({
|
|
5478
|
+
collection: MEDIA_FOLDERS,
|
|
5479
|
+
data: {
|
|
5480
|
+
name: key2,
|
|
5481
|
+
key: key2,
|
|
5482
|
+
parent: null,
|
|
5483
|
+
permissions: null,
|
|
5484
|
+
private: false,
|
|
5485
|
+
createdAt: now,
|
|
5486
|
+
updatedAt: now
|
|
5487
|
+
}
|
|
5488
|
+
});
|
|
5489
|
+
this.invalidate();
|
|
5490
|
+
return doc.id;
|
|
5491
|
+
} catch (error) {
|
|
5492
|
+
this.invalidate();
|
|
5493
|
+
const again = await find();
|
|
5494
|
+
if (again) return again.id;
|
|
5495
|
+
throw error;
|
|
5496
|
+
}
|
|
5497
|
+
}
|
|
5498
|
+
/** Whether a file in `folder` is in the folder with this key, or below it. */
|
|
5499
|
+
async inKeyed(key2, folder) {
|
|
5500
|
+
if (folder === null) return false;
|
|
5501
|
+
const rows = await this.rows();
|
|
5502
|
+
const root = [...rows.values()].find((r) => r.key === key2);
|
|
5503
|
+
return !!root && this.within(rows, [root.id]).some((id) => String(id) === String(folder));
|
|
5504
|
+
}
|
|
5272
5505
|
/** The user's level in a folder (`null`: the top level). */
|
|
5273
5506
|
async level(user, id) {
|
|
5274
5507
|
if (!this.applies(user)) return "manage";
|
|
@@ -5279,7 +5512,15 @@ var MediaFolders = class {
|
|
|
5279
5512
|
* they add none (no folder limits this user).
|
|
5280
5513
|
*/
|
|
5281
5514
|
async where(user, collection2, operation) {
|
|
5282
|
-
if (collection2 !== MEDIA && collection2 !== MEDIA_FOLDERS
|
|
5515
|
+
if (!this.enabled || collection2 !== MEDIA && collection2 !== MEDIA_FOLDERS) return true;
|
|
5516
|
+
if (!user) return collection2 === MEDIA ? PUBLIC_FILES : true;
|
|
5517
|
+
const limited = keyFolders(user);
|
|
5518
|
+
if (limited) {
|
|
5519
|
+
const ids = this.within(await this.rows(), limited);
|
|
5520
|
+
if (collection2 === MEDIA_FOLDERS) return { id: { in: ids } };
|
|
5521
|
+
return operation === "create" ? true : { folder: { in: ids } };
|
|
5522
|
+
}
|
|
5523
|
+
if (!this.applies(user)) return true;
|
|
5283
5524
|
if (operation === "create") return true;
|
|
5284
5525
|
const need = RANK[needed(collection2, operation)];
|
|
5285
5526
|
const rows = await this.rows();
|
|
@@ -5296,6 +5537,13 @@ var MediaFolders = class {
|
|
|
5296
5537
|
}
|
|
5297
5538
|
/** Refuses putting a file (`media`) or a subfolder in a folder the user may not. */
|
|
5298
5539
|
async checkTarget(user, collection2, folder) {
|
|
5540
|
+
const limited = keyFolders(user);
|
|
5541
|
+
if (limited) {
|
|
5542
|
+
const ids = this.within(await this.rows(), limited).map(String);
|
|
5543
|
+
if (folder === null || !ids.includes(String(folder)))
|
|
5544
|
+
throw new ForbiddenError("This API key may not add to this folder");
|
|
5545
|
+
return;
|
|
5546
|
+
}
|
|
5299
5547
|
if (!this.applies(user)) return;
|
|
5300
5548
|
const need = collection2 === MEDIA ? "edit" : "manage";
|
|
5301
5549
|
if (RANK[await this.level(user, folder)] < RANK[need])
|
|
@@ -5660,6 +5908,7 @@ async function createEasyCMS(config, options = {}) {
|
|
|
5660
5908
|
const cwd = options.cwd ?? process.cwd();
|
|
5661
5909
|
const storage = resolved3.upload.storage ?? localStorage({ dir: resolved3.upload.dir });
|
|
5662
5910
|
await storage.init?.({ cwd });
|
|
5911
|
+
if (resolved3.upload.privateStorage) await resolved3.upload.privateStorage.init?.({ cwd });
|
|
5663
5912
|
const db = await resolved3.db.init({
|
|
5664
5913
|
config: resolved3,
|
|
5665
5914
|
cwd,
|
|
@@ -5688,6 +5937,11 @@ var EasyCMS = class {
|
|
|
5688
5937
|
auth;
|
|
5689
5938
|
/** Where uploaded files are stored. */
|
|
5690
5939
|
storage;
|
|
5940
|
+
/**
|
|
5941
|
+
* Where files in private folders are stored (`upload.privateStorage`, else `storage` when it has
|
|
5942
|
+
* no public URLs); `null` when private folders can't be used.
|
|
5943
|
+
*/
|
|
5944
|
+
privateStorage;
|
|
5691
5945
|
/** Project root: relative paths in the config (e.g. `admin.modules`) start here. */
|
|
5692
5946
|
cwd;
|
|
5693
5947
|
/** Roles and their permissions (`auth.rbac`, Settings → Roles). */
|
|
@@ -5699,11 +5953,12 @@ var EasyCMS = class {
|
|
|
5699
5953
|
versions;
|
|
5700
5954
|
webhooks;
|
|
5701
5955
|
mailer;
|
|
5702
|
-
constructor(config, db, logger = consoleLogger, storage = localStorage({ dir: config.upload.dir }), cwd = process.cwd()) {
|
|
5956
|
+
constructor(config, db, logger = consoleLogger, storage = localStorage({ dir: config.upload.dir }), cwd = process.cwd(), privateStorage) {
|
|
5703
5957
|
this.config = config;
|
|
5704
5958
|
this.db = db;
|
|
5705
5959
|
this.logger = logger;
|
|
5706
5960
|
this.storage = storage;
|
|
5961
|
+
this.privateStorage = privateStorage !== void 0 ? privateStorage : config.upload.privateStorage ?? (typeof storage.url === "function" ? null : storage);
|
|
5707
5962
|
this.cwd = cwd;
|
|
5708
5963
|
this.versions = new VersionStore(db);
|
|
5709
5964
|
this.roles = new Roles(
|
|
@@ -5816,7 +6071,7 @@ var EasyCMS = class {
|
|
|
5816
6071
|
}
|
|
5817
6072
|
if (file.data.byteLength === 0)
|
|
5818
6073
|
throw new ValidationError(MEDIA, [{ field: "file", message: "is empty" }]);
|
|
5819
|
-
const mimeType = sniffMimeType(file.data);
|
|
6074
|
+
const mimeType = sniffMimeType(file.data, file.name);
|
|
5820
6075
|
if (!mimeType || !mimeAllowed(mimeType, mimeTypes)) {
|
|
5821
6076
|
throw new ValidationError(MEDIA, [
|
|
5822
6077
|
{
|
|
@@ -5826,17 +6081,27 @@ var EasyCMS = class {
|
|
|
5826
6081
|
]);
|
|
5827
6082
|
}
|
|
5828
6083
|
const random2 = randomBytes5(4).toString("hex");
|
|
5829
|
-
const
|
|
6084
|
+
const isPrivate = await this.folders.isPrivate(folderOf(MEDIA, data));
|
|
6085
|
+
const storage = this.storageFor(isPrivate);
|
|
6086
|
+
const filename = withPrivacy(storageKey(file.name, mimeType, random2), isPrivate);
|
|
5830
6087
|
const stored = [];
|
|
5831
6088
|
try {
|
|
5832
|
-
await
|
|
6089
|
+
await storage.put(filename, file.data, { contentType: mimeType });
|
|
5833
6090
|
stored.push(filename);
|
|
5834
6091
|
const dimensions = imageDimensions(file.data, mimeType);
|
|
5835
|
-
const sizes = await this.resizeImage(
|
|
6092
|
+
const sizes = await this.resizeImage(
|
|
6093
|
+
file.data,
|
|
6094
|
+
mimeType,
|
|
6095
|
+
filename,
|
|
6096
|
+
imageSizes,
|
|
6097
|
+
stored,
|
|
6098
|
+
storage
|
|
6099
|
+
);
|
|
5836
6100
|
const doc = await this.createDocument(
|
|
5837
6101
|
config,
|
|
5838
6102
|
{
|
|
5839
6103
|
...data,
|
|
6104
|
+
...this.folders.enabled ? { private: isPrivate } : {},
|
|
5840
6105
|
filename,
|
|
5841
6106
|
originalName: file.name.slice(0, 255),
|
|
5842
6107
|
mimeType,
|
|
@@ -5851,7 +6116,7 @@ var EasyCMS = class {
|
|
|
5851
6116
|
);
|
|
5852
6117
|
return doc;
|
|
5853
6118
|
} catch (error) {
|
|
5854
|
-
for (const key2 of stored) await
|
|
6119
|
+
for (const key2 of stored) await storage.delete(key2).catch(() => {
|
|
5855
6120
|
});
|
|
5856
6121
|
throw error;
|
|
5857
6122
|
}
|
|
@@ -5926,9 +6191,137 @@ var EasyCMS = class {
|
|
|
5926
6191
|
}
|
|
5927
6192
|
/** Public URL of a stored file. */
|
|
5928
6193
|
mediaURL(key2) {
|
|
6194
|
+
const base = `${this.config.serverURL?.replace(/\/+$/, "") ?? ""}${this.config.routes.api}`;
|
|
6195
|
+
if (isPrivateKey(key2)) return `${base}/media/private/${encodeURIComponent(key2)}`;
|
|
5929
6196
|
const custom = this.storage.url?.(key2);
|
|
5930
6197
|
if (custom) return custom;
|
|
5931
|
-
return `${
|
|
6198
|
+
return `${base}/media/file/${encodeURIComponent(key2)}`;
|
|
6199
|
+
}
|
|
6200
|
+
/**
|
|
6201
|
+
* A link to a private file that works without signing in until it expires: `expiresIn` in
|
|
6202
|
+
* seconds or as `'30m'`, `'1h'` (the default), `'7d'` (the most). Public files get their usual
|
|
6203
|
+
* URL. `size`: one of the file's resized copies. Links stop working when `secret` changes or
|
|
6204
|
+
* the file stops being private.
|
|
6205
|
+
*/
|
|
6206
|
+
signedMediaURL(doc, options = {}) {
|
|
6207
|
+
const sizes = doc.sizes ?? {};
|
|
6208
|
+
const key2 = options.size ? sizes[options.size]?.filename : doc.filename;
|
|
6209
|
+
if (typeof key2 !== "string" || !key2)
|
|
6210
|
+
throw new QueryError(
|
|
6211
|
+
options.size ? `The file has no "${options.size}" size` : "The document is not a file"
|
|
6212
|
+
);
|
|
6213
|
+
if (!isPrivateKey(key2)) return this.mediaURL(key2);
|
|
6214
|
+
const seconds = durationSeconds(options.expiresIn ?? "1h");
|
|
6215
|
+
const expires = Math.floor(Date.now() / 1e3) + seconds;
|
|
6216
|
+
return `${this.mediaURL(key2)}?expires=${expires}&signature=${this.mediaSignature(key2, expires)}`;
|
|
6217
|
+
}
|
|
6218
|
+
/** Whether a signed link to a private file is genuine and not expired. */
|
|
6219
|
+
verifyMediaSignature(key2, expires, signature) {
|
|
6220
|
+
const at = Number(expires);
|
|
6221
|
+
if (!signature || !Number.isInteger(at) || at < Date.now() / 1e3) return false;
|
|
6222
|
+
const expected = Buffer.from(this.mediaSignature(key2, at));
|
|
6223
|
+
const given = Buffer.from(signature);
|
|
6224
|
+
return expected.length === given.length && timingSafeEqual4(expected, given);
|
|
6225
|
+
}
|
|
6226
|
+
mediaSignature(key2, expires) {
|
|
6227
|
+
return createHmac5("sha256", this.config.secret).update(`media:${key2}:${expires}`).digest("base64url");
|
|
6228
|
+
}
|
|
6229
|
+
/**
|
|
6230
|
+
* How many documents use any of these files in a top-level upload field (rich text and fields
|
|
6231
|
+
* inside groups, arrays and blocks are not counted). Drafts count too.
|
|
6232
|
+
*/
|
|
6233
|
+
async mediaUsage(ids) {
|
|
6234
|
+
if (ids.length === 0) return 0;
|
|
6235
|
+
let count = 0;
|
|
6236
|
+
for (const config of this.config.collections) {
|
|
6237
|
+
if (INTERNAL_COLLECTIONS.has(config.slug)) continue;
|
|
6238
|
+
for (const field of config.fields) {
|
|
6239
|
+
if (field.type !== "upload") continue;
|
|
6240
|
+
count += await this.db.count({
|
|
6241
|
+
collection: config.slug,
|
|
6242
|
+
where: { [field.name]: { in: [...ids] } }
|
|
6243
|
+
});
|
|
6244
|
+
}
|
|
6245
|
+
}
|
|
6246
|
+
return count;
|
|
6247
|
+
}
|
|
6248
|
+
/** The storage for public files, or for private ones. */
|
|
6249
|
+
storageFor(isPrivate) {
|
|
6250
|
+
if (!isPrivate) return this.storage;
|
|
6251
|
+
if (!this.privateStorage)
|
|
6252
|
+
throw new EasyCMSError(
|
|
6253
|
+
"Private folders need upload.privateStorage: the storage has public URLs, so private files must be kept elsewhere",
|
|
6254
|
+
500
|
|
6255
|
+
);
|
|
6256
|
+
return this.privateStorage;
|
|
6257
|
+
}
|
|
6258
|
+
/**
|
|
6259
|
+
* Copies a file and its resized copies to the other storage under their new names (`.private`
|
|
6260
|
+
* added or removed). Returns the fields to save, and `cleanUp` to delete the old copies once
|
|
6261
|
+
* the document points to the new ones.
|
|
6262
|
+
*/
|
|
6263
|
+
async relocate(doc, toPrivate) {
|
|
6264
|
+
const from = this.storageFor(doc.private === true);
|
|
6265
|
+
const to = this.storageFor(toPrivate);
|
|
6266
|
+
const contentType = String(doc.mimeType ?? "application/octet-stream");
|
|
6267
|
+
const filename = withPrivacy(String(doc.filename), toPrivate);
|
|
6268
|
+
const old = [String(doc.filename)];
|
|
6269
|
+
const copy = async (source, target) => {
|
|
6270
|
+
const file = await from.get(source);
|
|
6271
|
+
if (file) await to.put(target, file.body, { contentType });
|
|
6272
|
+
};
|
|
6273
|
+
await copy(String(doc.filename), filename);
|
|
6274
|
+
const sizes = {};
|
|
6275
|
+
for (const [name, size] of Object.entries(
|
|
6276
|
+
doc.sizes ?? {}
|
|
6277
|
+
)) {
|
|
6278
|
+
const key2 = sizeKey(filename, name);
|
|
6279
|
+
await copy(String(size.filename), key2);
|
|
6280
|
+
old.push(String(size.filename));
|
|
6281
|
+
sizes[name] = { ...size, filename: key2 };
|
|
6282
|
+
}
|
|
6283
|
+
return {
|
|
6284
|
+
patch: { filename, sizes, private: toPrivate },
|
|
6285
|
+
cleanUp: async () => {
|
|
6286
|
+
for (const key2 of old) await from.delete(key2).catch(() => {
|
|
6287
|
+
});
|
|
6288
|
+
}
|
|
6289
|
+
};
|
|
6290
|
+
}
|
|
6291
|
+
/** A folder's name, parent and permissions; private only with somewhere private to keep files. */
|
|
6292
|
+
async validateFolder(data, self) {
|
|
6293
|
+
await this.folders.validate(data, self);
|
|
6294
|
+
if (data.private === true && !this.privateStorage)
|
|
6295
|
+
throw new ValidationError(MEDIA_FOLDERS, [
|
|
6296
|
+
{
|
|
6297
|
+
field: "private",
|
|
6298
|
+
message: "needs upload.privateStorage: the storage gives files public URLs, so private files must be kept elsewhere"
|
|
6299
|
+
}
|
|
6300
|
+
]);
|
|
6301
|
+
}
|
|
6302
|
+
/** After a folder became private or public (or moved): its files follow, in every subfolder. */
|
|
6303
|
+
async settlePrivacy(folder) {
|
|
6304
|
+
for (const f of await this.folders.subtree(folder))
|
|
6305
|
+
await this.settleFiles({ folder: { equals: f.id } }, f.private);
|
|
6306
|
+
}
|
|
6307
|
+
/** Files at the top level are public. */
|
|
6308
|
+
async settleTopLevel() {
|
|
6309
|
+
await this.settleFiles({ folder: { exists: false } }, false);
|
|
6310
|
+
}
|
|
6311
|
+
async settleFiles(where, isPrivate) {
|
|
6312
|
+
const found = await this.db.find({
|
|
6313
|
+
collection: MEDIA,
|
|
6314
|
+
where: andWhere(where, isPrivate ? PUBLIC_FILES : { private: { equals: true } }),
|
|
6315
|
+
sort: [],
|
|
6316
|
+
limit: 0,
|
|
6317
|
+
page: 1
|
|
6318
|
+
});
|
|
6319
|
+
for (const doc of found.docs) {
|
|
6320
|
+
const moved = await this.relocate(doc, isPrivate);
|
|
6321
|
+
const { id, ...rest } = doc;
|
|
6322
|
+
await this.db.update({ collection: MEDIA, id, data: { ...rest, ...moved.patch } });
|
|
6323
|
+
await moved.cleanUp();
|
|
6324
|
+
}
|
|
5932
6325
|
}
|
|
5933
6326
|
async update(collection2, id, data, options = {}) {
|
|
5934
6327
|
return await this.updateDocument(collection2, id, asObject(data, collection2), options);
|
|
@@ -6210,13 +6603,26 @@ var EasyCMS = class {
|
|
|
6210
6603
|
}
|
|
6211
6604
|
if (config.slug === USERS) await this.guardLastAdmin(parsed, existing, prepared);
|
|
6212
6605
|
if (password !== void 0) prepared.passwordHash = await hashPassword(password);
|
|
6213
|
-
if (collection2 === MEDIA_FOLDERS) await this.
|
|
6606
|
+
if (collection2 === MEDIA_FOLDERS) await this.validateFolder(prepared, parsed);
|
|
6607
|
+
let moved;
|
|
6608
|
+
if (collection2 === MEDIA && this.folders.enabled) {
|
|
6609
|
+
const toPrivate = await this.folders.isPrivate(folderOf(MEDIA, prepared));
|
|
6610
|
+
if (toPrivate !== (existing.private === true)) {
|
|
6611
|
+
moved = await this.relocate(existing, toPrivate);
|
|
6612
|
+
Object.assign(prepared, moved.patch);
|
|
6613
|
+
}
|
|
6614
|
+
}
|
|
6214
6615
|
const doc = await this.db.update({
|
|
6215
6616
|
collection: collection2,
|
|
6216
6617
|
id: parsed,
|
|
6217
6618
|
data: { ...prepared, createdAt: existing.createdAt, updatedAt: now }
|
|
6218
6619
|
});
|
|
6219
|
-
if (
|
|
6620
|
+
if (moved) await moved.cleanUp();
|
|
6621
|
+
if (collection2 === MEDIA_FOLDERS) {
|
|
6622
|
+
this.folders.invalidate();
|
|
6623
|
+
if (prepared.private !== existing.private || String(prepared.parent ?? null) !== String(existing.parent ?? null))
|
|
6624
|
+
await this.settlePrivacy(parsed);
|
|
6625
|
+
}
|
|
6220
6626
|
if (!live) await this.saveVersion(config, collectionParent(collection2), parsed, doc, guard);
|
|
6221
6627
|
if (password !== void 0) await this.auth.revokeSessions(parsed);
|
|
6222
6628
|
await this.notify(config.hooks?.afterChange, "afterChange", config.slug, {
|
|
@@ -6261,7 +6667,12 @@ var EasyCMS = class {
|
|
|
6261
6667
|
}
|
|
6262
6668
|
if (collection2 === MEDIA_FOLDERS) await this.folders.release(existing);
|
|
6263
6669
|
await this.db.delete({ collection: collection2, id: parsed });
|
|
6264
|
-
if (collection2 === MEDIA_FOLDERS)
|
|
6670
|
+
if (collection2 === MEDIA_FOLDERS) {
|
|
6671
|
+
this.folders.invalidate();
|
|
6672
|
+
const parent = existing.parent ?? null;
|
|
6673
|
+
if (parent !== null) await this.settlePrivacy(parent);
|
|
6674
|
+
else await this.settleTopLevel();
|
|
6675
|
+
}
|
|
6265
6676
|
if (versionLimit(config)) await this.versions.deleteAll(collectionParent(collection2), parsed);
|
|
6266
6677
|
if (config.schedule) {
|
|
6267
6678
|
for (const job of await this.pendingJobs(collection2, parsed))
|
|
@@ -7005,7 +7416,7 @@ var EasyCMS = class {
|
|
|
7005
7416
|
prepared
|
|
7006
7417
|
);
|
|
7007
7418
|
if (password !== void 0) prepared.passwordHash = await hashPassword(password);
|
|
7008
|
-
if (collection2 === MEDIA_FOLDERS) await this.
|
|
7419
|
+
if (collection2 === MEDIA_FOLDERS) await this.validateFolder(prepared, void 0);
|
|
7009
7420
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
7010
7421
|
const doc = await this.db.create({
|
|
7011
7422
|
collection: collection2,
|
|
@@ -7165,7 +7576,7 @@ var EasyCMS = class {
|
|
|
7165
7576
|
}
|
|
7166
7577
|
}
|
|
7167
7578
|
/** Writes resized copies with sharp, when installed and configured. */
|
|
7168
|
-
async resizeImage(data, mimeType, filename, sizes, stored) {
|
|
7579
|
+
async resizeImage(data, mimeType, filename, sizes, stored, storage = this.storage) {
|
|
7169
7580
|
if (sizes.length === 0 || !["image/png", "image/jpeg", "image/webp", "image/avif"].includes(mimeType))
|
|
7170
7581
|
return {};
|
|
7171
7582
|
const sharp = await loadSharp();
|
|
@@ -7183,8 +7594,8 @@ var EasyCMS = class {
|
|
|
7183
7594
|
fit: size.fit ?? "cover",
|
|
7184
7595
|
withoutEnlargement: true
|
|
7185
7596
|
}).toBuffer({ resolveWithObject: true });
|
|
7186
|
-
const key2 = filename
|
|
7187
|
-
await
|
|
7597
|
+
const key2 = sizeKey(filename, size.name);
|
|
7598
|
+
await storage.put(key2, new Uint8Array(out), { contentType: mimeType });
|
|
7188
7599
|
stored.push(key2);
|
|
7189
7600
|
result[size.name] = {
|
|
7190
7601
|
filename: key2,
|
|
@@ -7352,6 +7763,14 @@ var EasyCMS = class {
|
|
|
7352
7763
|
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"})`
|
|
7353
7764
|
});
|
|
7354
7765
|
}
|
|
7766
|
+
if (ref.folderOnly && this.folders.enabled && !await this.folders.inKeyed(
|
|
7767
|
+
ref.folderOnly,
|
|
7768
|
+
doc.folder ?? null
|
|
7769
|
+
))
|
|
7770
|
+
errors.push({
|
|
7771
|
+
field: ref.field,
|
|
7772
|
+
message: `must be a file from the "${ref.folderOnly}" folder (${doc.filename ?? ref.id} is not)`
|
|
7773
|
+
});
|
|
7355
7774
|
}
|
|
7356
7775
|
}
|
|
7357
7776
|
return errors;
|
|
@@ -7418,6 +7837,15 @@ function folderOf(collection2, data) {
|
|
|
7418
7837
|
const value = data[collection2 === MEDIA ? "folder" : "parent"];
|
|
7419
7838
|
return value === void 0 || value === "" ? null : value;
|
|
7420
7839
|
}
|
|
7840
|
+
var MAX_LINK = 7 * 24 * 60 * 60;
|
|
7841
|
+
function durationSeconds(value) {
|
|
7842
|
+
const match = typeof value === "string" ? /^(\d+)\s*([smhd])$/.exec(value.trim()) : null;
|
|
7843
|
+
const seconds = typeof value === "number" ? value : match ? Number(match[1]) * { s: 1, m: 60, h: 3600, d: 86400 }[match[2]] : Number.NaN;
|
|
7844
|
+
if (!Number.isFinite(seconds) || seconds <= 0)
|
|
7845
|
+
throw new QueryError(`expiresIn must be seconds or like '30m', '1h', '7d' (got ${value})`);
|
|
7846
|
+
if (seconds > MAX_LINK) throw new QueryError("expiresIn can be at most 7 days");
|
|
7847
|
+
return Math.floor(seconds);
|
|
7848
|
+
}
|
|
7421
7849
|
|
|
7422
7850
|
// src/rest/admin-modules.ts
|
|
7423
7851
|
import { createHash as createHash3 } from "crypto";
|
|
@@ -7666,7 +8094,7 @@ function componentRef(component) {
|
|
|
7666
8094
|
async function allowed(access, user) {
|
|
7667
8095
|
return await evaluateAccess(access, { user }) !== false;
|
|
7668
8096
|
}
|
|
7669
|
-
async function serializeFields(fields, checks, localized) {
|
|
8097
|
+
async function serializeFields(fields, checks, localized, folderId) {
|
|
7670
8098
|
const { update: update2 } = checks;
|
|
7671
8099
|
const out = [];
|
|
7672
8100
|
for (const field of fields) {
|
|
@@ -7718,12 +8146,14 @@ async function serializeFields(fields, checks, localized) {
|
|
|
7718
8146
|
if (field.hasMany) f.hasMany = true;
|
|
7719
8147
|
if (field.minRows !== void 0) f.minRows = field.minRows;
|
|
7720
8148
|
if (field.maxRows !== void 0) f.maxRows = field.maxRows;
|
|
7721
|
-
if (field.mimeTypes) f.mimeTypes =
|
|
8149
|
+
if (field.mimeTypes) f.mimeTypes = expandMimeTypes(field.mimeTypes);
|
|
8150
|
+
if (field.folder && folderId)
|
|
8151
|
+
f.folder = { id: await folderId(field.folder), only: field.folderOnly === true };
|
|
7722
8152
|
break;
|
|
7723
8153
|
case "array":
|
|
7724
8154
|
if (field.minRows !== void 0) f.minRows = field.minRows;
|
|
7725
8155
|
if (field.maxRows !== void 0) f.maxRows = field.maxRows;
|
|
7726
|
-
f.fields = await serializeFields(field.fields, checks, localized);
|
|
8156
|
+
f.fields = await serializeFields(field.fields, checks, localized, folderId);
|
|
7727
8157
|
break;
|
|
7728
8158
|
case "blocks":
|
|
7729
8159
|
if (field.minRows !== void 0) f.minRows = field.minRows;
|
|
@@ -7732,12 +8162,12 @@ async function serializeFields(fields, checks, localized) {
|
|
|
7732
8162
|
field.blocks.map(async (block) => ({
|
|
7733
8163
|
slug: block.slug,
|
|
7734
8164
|
...block.labels ? { labels: block.labels } : {},
|
|
7735
|
-
fields: await serializeFields(block.fields, checks, localized)
|
|
8165
|
+
fields: await serializeFields(block.fields, checks, localized, folderId)
|
|
7736
8166
|
}))
|
|
7737
8167
|
);
|
|
7738
8168
|
break;
|
|
7739
8169
|
case "group":
|
|
7740
|
-
f.fields = await serializeFields(field.fields, checks, localized);
|
|
8170
|
+
f.fields = await serializeFields(field.fields, checks, localized, folderId);
|
|
7741
8171
|
break;
|
|
7742
8172
|
}
|
|
7743
8173
|
out.push(f);
|
|
@@ -7756,7 +8186,12 @@ async function may(cms, access, user, target, operation) {
|
|
|
7756
8186
|
}
|
|
7757
8187
|
async function collection(cms, config, user, localized, roles) {
|
|
7758
8188
|
const target = { collection: config.slug };
|
|
7759
|
-
const fields = await serializeFields(
|
|
8189
|
+
const fields = await serializeFields(
|
|
8190
|
+
config.fields,
|
|
8191
|
+
await checkers(cms, user),
|
|
8192
|
+
localized,
|
|
8193
|
+
folderIds(cms)
|
|
8194
|
+
);
|
|
7760
8195
|
if (config.slug === USERS && cms.roles.enabled) {
|
|
7761
8196
|
const role = fields.find((f) => f.name === "role");
|
|
7762
8197
|
if (role) {
|
|
@@ -7801,7 +8236,7 @@ async function global(cms, config, user, localized) {
|
|
|
7801
8236
|
versions: Boolean(config.versions),
|
|
7802
8237
|
preview: typeof config.preview === "function",
|
|
7803
8238
|
schedule: config.schedule === true,
|
|
7804
|
-
fields: await serializeFields(config.fields, checks, localized),
|
|
8239
|
+
fields: await serializeFields(config.fields, checks, localized, folderIds(cms)),
|
|
7805
8240
|
permissions: {
|
|
7806
8241
|
read: await may(cms, config.access?.read, user, target, "read"),
|
|
7807
8242
|
update: await may(cms, config.access?.update, user, target, "update"),
|
|
@@ -7842,7 +8277,17 @@ async function adminSchema(cms, user, origin) {
|
|
|
7842
8277
|
globals: await Promise.all(cms.config.globals.map((g) => global(cms, g, user, localized))),
|
|
7843
8278
|
modules: adminModuleUrls(cms),
|
|
7844
8279
|
uploadFromURL: cms.config.upload.fromURL !== void 0,
|
|
7845
|
-
|
|
8280
|
+
upload: {
|
|
8281
|
+
maxFileSize: cms.config.upload.maxFileSize,
|
|
8282
|
+
mimeTypes: expandMimeTypes(cms.config.upload.mimeTypes)
|
|
8283
|
+
},
|
|
8284
|
+
...cms.folders.enabled ? {
|
|
8285
|
+
folders: {
|
|
8286
|
+
permissions: admin && cms.roles.enabled,
|
|
8287
|
+
// Admins can make folders private when there is somewhere private to keep files.
|
|
8288
|
+
private: admin && cms.privateStorage !== null
|
|
8289
|
+
}
|
|
8290
|
+
} : {},
|
|
7846
8291
|
views,
|
|
7847
8292
|
rbac: cms.roles.enabled,
|
|
7848
8293
|
providers: cms.auth.sso.providers(),
|
|
@@ -7889,9 +8334,12 @@ async function widgets(cms, user) {
|
|
|
7889
8334
|
}
|
|
7890
8335
|
return out;
|
|
7891
8336
|
}
|
|
8337
|
+
function folderIds(cms) {
|
|
8338
|
+
return cms.folders.enabled ? (key2) => cms.folders.keyed(key2) : void 0;
|
|
8339
|
+
}
|
|
7892
8340
|
|
|
7893
8341
|
// src/version.ts
|
|
7894
|
-
var VERSION = true ? "0.
|
|
8342
|
+
var VERSION = true ? "0.40.0" : "0.0.0-dev";
|
|
7895
8343
|
|
|
7896
8344
|
// src/rest/admin-status.ts
|
|
7897
8345
|
var DAY2 = 864e5;
|
|
@@ -8460,6 +8908,12 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
8460
8908
|
if (parsed === void 0) throw new HttpError("Not found", 404);
|
|
8461
8909
|
return { body: await cms.roles.owned(parsed) };
|
|
8462
8910
|
}
|
|
8911
|
+
if (first === "admin" && second === "media-usage" && segments.length === 2) {
|
|
8912
|
+
if (!ctx.user || ctx.user.apiKey) throw new UnauthorizedError();
|
|
8913
|
+
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
8914
|
+
const ids = (ctx.url.searchParams.get("ids") ?? "").split(",").map((id2) => parseId(id2)).filter((id2) => id2 !== void 0);
|
|
8915
|
+
return { body: { count: await cms.mediaUsage(ids) } };
|
|
8916
|
+
}
|
|
8463
8917
|
if (first === "admin" && second === "roles") {
|
|
8464
8918
|
if (!ctx.user) throw new UnauthorizedError();
|
|
8465
8919
|
if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
|
|
@@ -8606,6 +9060,10 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
8606
9060
|
if (method !== "GET" && method !== "HEAD") throw methodNotAllowed(ctx, "GET, HEAD");
|
|
8607
9061
|
return { body: await serveFile(cms, third, method === "HEAD") };
|
|
8608
9062
|
}
|
|
9063
|
+
if (first === MEDIA && second === "private" && third !== void 0 && segments.length === 3) {
|
|
9064
|
+
if (method !== "GET" && method !== "HEAD") throw methodNotAllowed(ctx, "GET, HEAD");
|
|
9065
|
+
return { body: await servePrivateFile(cms, ctx, third, method === "HEAD") };
|
|
9066
|
+
}
|
|
8609
9067
|
if (first === MEDIA && second === void 0 && method === "POST") {
|
|
8610
9068
|
if ((ctx.request.headers.get("content-type") ?? "").toLowerCase().startsWith("application/json")) {
|
|
8611
9069
|
const { url, ...data2 } = await readJson(ctx.request);
|
|
@@ -8886,16 +9344,62 @@ async function serveFile(cms, key2, head) {
|
|
|
8886
9344
|
const file = await cms.storage.get(key2);
|
|
8887
9345
|
if (!file) throw new HttpError("Not found", 404);
|
|
8888
9346
|
const extension = key2.slice(key2.lastIndexOf(".") + 1);
|
|
9347
|
+
const type = TYPE_BY_EXTENSION[extension] ?? "application/octet-stream";
|
|
8889
9348
|
return new Response(
|
|
8890
9349
|
head ? null : file.body,
|
|
8891
9350
|
{
|
|
8892
9351
|
status: 200,
|
|
8893
9352
|
headers: {
|
|
8894
|
-
"content-type":
|
|
9353
|
+
"content-type": type,
|
|
8895
9354
|
"content-length": String(file.size),
|
|
8896
9355
|
"cache-control": "public, max-age=31536000, immutable",
|
|
8897
9356
|
"x-content-type-options": "nosniff",
|
|
8898
|
-
|
|
9357
|
+
// Browsers won't show a PDF in a sandbox; their PDF viewers run apart from the site.
|
|
9358
|
+
...type === "application/pdf" ? {} : {
|
|
9359
|
+
"content-security-policy": "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox"
|
|
9360
|
+
},
|
|
9361
|
+
"cross-origin-resource-policy": "cross-origin"
|
|
9362
|
+
}
|
|
9363
|
+
}
|
|
9364
|
+
);
|
|
9365
|
+
}
|
|
9366
|
+
async function servePrivateFile(cms, ctx, key2, head) {
|
|
9367
|
+
if (!PRIVATE_KEY.test(key2) || !cms.privateStorage) throw new HttpError("Not found", 404);
|
|
9368
|
+
const query = ctx.url.searchParams;
|
|
9369
|
+
const signed = cms.verifyMediaSignature(key2, query.get("expires"), query.get("signature"));
|
|
9370
|
+
if (!signed) {
|
|
9371
|
+
const token = /-([0-9a-f]{8})\.private[.-]/.exec(key2)?.[1];
|
|
9372
|
+
if (!ctx.user || !token) throw new HttpError("Not found", 404);
|
|
9373
|
+
const found = await cms.find(MEDIA, {
|
|
9374
|
+
where: { filename: { like: `-${token}.private.` } },
|
|
9375
|
+
limit: 5,
|
|
9376
|
+
depth: 0,
|
|
9377
|
+
user: ctx.user,
|
|
9378
|
+
overrideAccess: false
|
|
9379
|
+
});
|
|
9380
|
+
const owns = found.docs.some(
|
|
9381
|
+
(d) => d.filename === key2 || Object.values(d.sizes ?? {}).some(
|
|
9382
|
+
(s) => s.filename === key2
|
|
9383
|
+
)
|
|
9384
|
+
);
|
|
9385
|
+
if (!owns) throw new HttpError("Not found", 404);
|
|
9386
|
+
}
|
|
9387
|
+
const file = await cms.privateStorage.get(key2);
|
|
9388
|
+
if (!file) throw new HttpError("Not found", 404);
|
|
9389
|
+
const type = TYPE_BY_EXTENSION[key2.slice(key2.lastIndexOf(".") + 1)] ?? "application/octet-stream";
|
|
9390
|
+
return new Response(
|
|
9391
|
+
head ? null : file.body,
|
|
9392
|
+
{
|
|
9393
|
+
status: 200,
|
|
9394
|
+
headers: {
|
|
9395
|
+
"content-type": type,
|
|
9396
|
+
"content-length": String(file.size),
|
|
9397
|
+
// Not in shared caches; a short while in the browser.
|
|
9398
|
+
"cache-control": "private, max-age=300",
|
|
9399
|
+
"x-content-type-options": "nosniff",
|
|
9400
|
+
...type === "application/pdf" ? {} : {
|
|
9401
|
+
"content-security-policy": "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox"
|
|
9402
|
+
},
|
|
8899
9403
|
"cross-origin-resource-policy": "cross-origin"
|
|
8900
9404
|
}
|
|
8901
9405
|
}
|
|
@@ -9245,6 +9749,7 @@ export {
|
|
|
9245
9749
|
MAX_DEPTH,
|
|
9246
9750
|
MEDIA,
|
|
9247
9751
|
MEDIA_FOLDERS,
|
|
9752
|
+
MIME_GROUPS,
|
|
9248
9753
|
MIN_PASSWORD_LENGTH,
|
|
9249
9754
|
MIN_SECRET_LENGTH,
|
|
9250
9755
|
MediaFolders,
|
|
@@ -9274,6 +9779,7 @@ export {
|
|
|
9274
9779
|
defineConfig,
|
|
9275
9780
|
defineFieldType,
|
|
9276
9781
|
definePlugin,
|
|
9782
|
+
expandMimeTypes,
|
|
9277
9783
|
fillMissing,
|
|
9278
9784
|
findConfigFile,
|
|
9279
9785
|
generateSlugs,
|