@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.
Files changed (3) hide show
  1. package/dist/index.d.ts +151 -9
  2. package/dist/index.js +635 -129
  3. 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: [...mediaFields(), ...folders ? [folderField] : [], ...custom?.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 cms.storage.delete(key2);
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" && /^[\w.+-]+\/(\*|[\w.+-]+)$/.test(t))) {
4639
- add(`${path}.mimeTypes`, "must be a list of MIME types", "e.g. mimeTypes: ['image/*']");
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 || !this.applies(user)) return true;
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 filename = storageKey(file.name, mimeType, random2);
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 this.storage.put(filename, file.data, { contentType: mimeType });
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(file.data, mimeType, filename, imageSizes, stored);
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 this.storage.delete(key2).catch(() => {
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 `${this.config.serverURL?.replace(/\/+$/, "") ?? ""}${this.config.routes.api}/media/file/${encodeURIComponent(key2)}`;
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.folders.validate(prepared, parsed);
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 (collection2 === MEDIA_FOLDERS) this.folders.invalidate();
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) this.folders.invalidate();
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.folders.validate(prepared, void 0);
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.replace(/\.([^.]+)$/, `-${size.name}.$1`);
7187
- await this.storage.put(key2, new Uint8Array(out), { contentType: mimeType });
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 = [...field.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(config.fields, await checkers(cms, user), localized);
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
- ...cms.folders.enabled ? { folders: { permissions: admin && cms.roles.enabled } } : {},
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.38.0" : "0.0.0-dev";
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": TYPE_BY_EXTENSION[extension] ?? "application/octet-stream",
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
- "content-security-policy": "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox",
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,