@easy-cms/core 0.39.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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 ?? []
@@ -1062,11 +1095,28 @@ function imageDimensions(data, type) {
1062
1095
  }
1063
1096
  return void 0;
1064
1097
  }
1098
+ function typeFromName(name) {
1099
+ const extension = /\.([a-z0-9]+)$/i.exec(name)?.[1]?.toLowerCase();
1100
+ if (!extension) return void 0;
1101
+ if (extension === "jpeg") return "image/jpeg";
1102
+ return Object.entries(EXTENSIONS).find(([, ext]) => ext === extension)?.[0];
1103
+ }
1065
1104
  function storageKey(originalName, type, random2) {
1066
1105
  const name = originalName.split(/[\\/]/).pop() ?? "";
1067
1106
  const base = name.replace(/(?<=.)\.[^.]*$/, "").normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{M}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "file";
1068
1107
  return `${base}-${random2}.${EXTENSIONS[type] ?? "bin"}`;
1069
1108
  }
1109
+ function isPrivateKey(key2) {
1110
+ return /\.private[.-]/.test(key2);
1111
+ }
1112
+ function withPrivacy(key2, isPrivate) {
1113
+ const plain = key2.replace(/\.private(?=\.[^.]+$)/, "");
1114
+ return isPrivate ? plain.replace(/\.([^.]+)$/, ".private.$1") : plain;
1115
+ }
1116
+ function sizeKey(filename, size) {
1117
+ return filename.replace(/\.([^.]+)$/, `-${size}.$1`);
1118
+ }
1119
+ var PRIVATE_KEY = /^[\p{L}\p{M}\p{N}-]+\.private(-[\p{L}\p{N}_-]+)?\.[a-z0-9]+$/u;
1070
1120
 
1071
1121
  // src/fields.ts
1072
1122
  var isHasMany = (field) => (field.type === "select" || field.type === "relationship" || field.type === "upload") && field.hasMany === true;
@@ -2806,7 +2856,8 @@ async function normalizeValue(field, raw, path, options, fail, errors, reference
2806
2856
  collection: target,
2807
2857
  id,
2808
2858
  ...field.type === "relationship" && field.filterOptions ? { filterOptions: field.filterOptions } : {},
2809
- ...field.type === "upload" && field.mimeTypes ? { mimeTypes: field.mimeTypes } : {}
2859
+ ...field.type === "upload" && field.mimeTypes ? { mimeTypes: field.mimeTypes } : {},
2860
+ ...field.type === "upload" && field.folder && field.folderOnly ? { folderOnly: field.folder } : {}
2810
2861
  });
2811
2862
  }
2812
2863
  return many ? ids : ids[0];
@@ -4503,6 +4554,25 @@ function validateCors(cors, add) {
4503
4554
  }
4504
4555
  function validateUpload(config, add) {
4505
4556
  const upload = config.upload;
4557
+ if (upload?.folders !== true) {
4558
+ const walk = (fields, path) => {
4559
+ for (const [i, field] of fields.entries()) {
4560
+ const at = `${path}.fields[${i}]`;
4561
+ if (field.type === "upload" && field.folder !== void 0)
4562
+ add(`${at}.folder`, "needs upload.folders: true", "upload: { folders: true }");
4563
+ if ((field.type === "group" || field.type === "array") && Array.isArray(field.fields))
4564
+ walk(field.fields, at);
4565
+ if (field.type === "blocks" && Array.isArray(field.blocks)) {
4566
+ for (const [b, block] of field.blocks.entries())
4567
+ if (Array.isArray(block?.fields)) walk(block.fields, `${at}.blocks[${b}]`);
4568
+ }
4569
+ }
4570
+ };
4571
+ for (const [i, c] of (config.collections ?? []).entries())
4572
+ if (Array.isArray(c?.fields)) walk(c.fields, `collections[${i}]`);
4573
+ for (const [i, g] of (config.globals ?? []).entries())
4574
+ if (Array.isArray(g?.fields)) walk(g.fields, `globals[${i}]`);
4575
+ }
4506
4576
  if (upload === void 0) return;
4507
4577
  if (upload.maxFileSize !== void 0 && !(Number.isInteger(upload.maxFileSize) && upload.maxFileSize > 0)) {
4508
4578
  add("upload.maxFileSize", "must be a positive integer (bytes)");
@@ -4519,6 +4589,9 @@ function validateUpload(config, add) {
4519
4589
  );
4520
4590
  if (upload.folders !== void 0 && typeof upload.folders !== "boolean")
4521
4591
  add("upload.folders", "must be true or false");
4592
+ const privateStorage = upload.privateStorage;
4593
+ if (privateStorage !== void 0 && (typeof privateStorage !== "object" || privateStorage === null || typeof privateStorage.get !== "function"))
4594
+ add("upload.privateStorage", "must be a storage adapter", "e.g. s3Storage({ \u2026 })");
4522
4595
  const fromURL = upload.fromURL;
4523
4596
  if (fromURL === void 0) return;
4524
4597
  if (typeof fromURL !== "object" || fromURL === null) {
@@ -4544,6 +4617,7 @@ function validateUpload(config, add) {
4544
4617
  if (allowPrivate !== void 0 && typeof allowPrivate !== "boolean")
4545
4618
  add("upload.fromURL.allowPrivate", "must be true or false");
4546
4619
  }
4620
+ var FOLDER_KEY = /^[a-z0-9][a-z0-9_-]{0,59}$/;
4547
4621
  var MIME_PATTERN = /^([\w.+-]+\/(\*|[\w.+-]+)|documents|office|archives)$/i;
4548
4622
  var HOST_PATTERN = /^(\*|(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*)$/i;
4549
4623
  function validateAudit(config, add) {
@@ -4792,6 +4866,14 @@ function validateField(field, path, siblings, collectionSlugs, add) {
4792
4866
  break;
4793
4867
  case "upload":
4794
4868
  validateManyRange(field, path, add);
4869
+ if (field.folder !== void 0 && !FOLDER_KEY.test(String(field.folder)))
4870
+ add(
4871
+ `${path}.folder`,
4872
+ 'must be a folder key: lowercase letters, digits, "-" or "_"',
4873
+ "e.g. folder: 'banners'"
4874
+ );
4875
+ if (field.folderOnly !== void 0 && field.folder === void 0)
4876
+ add(`${path}.folderOnly`, "needs folder");
4795
4877
  if (field.mimeTypes !== void 0) {
4796
4878
  const types = field.mimeTypes;
4797
4879
  if (!Array.isArray(types) || types.length === 0 || !types.every((t) => typeof t === "string" && MIME_PATTERN.test(t))) {
@@ -5070,7 +5152,8 @@ async function resolveConfig(input) {
5070
5152
  imageSizes: config.upload?.imageSizes ?? [],
5071
5153
  folders: config.upload?.folders === true,
5072
5154
  ...config.upload?.storage ? { storage: config.upload.storage } : {},
5073
- ...config.upload?.fromURL ? { fromURL: config.upload.fromURL } : {}
5155
+ ...config.upload?.fromURL ? { fromURL: config.upload.fromURL } : {},
5156
+ ...config.upload?.privateStorage ? { privateStorage: config.upload.privateStorage } : {}
5074
5157
  },
5075
5158
  auth: {
5076
5159
  roles: config.auth?.roles ?? DEFAULT_ROLES,
@@ -5173,7 +5256,7 @@ async function loadConfig(options = {}) {
5173
5256
  }
5174
5257
 
5175
5258
  // src/local-api.ts
5176
- import { randomBytes as randomBytes5 } from "crypto";
5259
+ import { createHmac as createHmac5, randomBytes as randomBytes5, timingSafeEqual as timingSafeEqual4 } from "crypto";
5177
5260
 
5178
5261
  // src/localization.ts
5179
5262
  var ALL_LOCALES = "all";
@@ -5286,6 +5369,14 @@ var silentLogger = { info() {
5286
5369
  var FOLDER_LEVELS = ["view", "edit", "manage"];
5287
5370
  var RANK = { none: 0, view: 1, edit: 2, manage: 3 };
5288
5371
  var TTL2 = 1e4;
5372
+ var PUBLIC_FILES = {
5373
+ or: [{ private: { equals: false } }, { private: { exists: false } }]
5374
+ };
5375
+ function keyFolders(user) {
5376
+ const key2 = user?.apiKey;
5377
+ const folders = key2?.permissions?.folders;
5378
+ return folders && folders.length > 0 ? folders : void 0;
5379
+ }
5289
5380
  function needed(collection2, operation) {
5290
5381
  if (operation === "read") return "view";
5291
5382
  return collection2 === MEDIA ? "edit" : "manage";
@@ -5318,6 +5409,8 @@ var MediaFolders = class {
5318
5409
  id: doc.id,
5319
5410
  parent: doc.parent ?? null,
5320
5411
  name: String(doc.name ?? ""),
5412
+ key: typeof doc.key === "string" && doc.key ? doc.key : null,
5413
+ private: doc.private === true,
5321
5414
  permissions: permissions && typeof permissions === "object" && !Array.isArray(permissions) ? permissions : null
5322
5415
  });
5323
5416
  }
@@ -5338,6 +5431,98 @@ var MediaFolders = class {
5338
5431
  }
5339
5432
  return "manage";
5340
5433
  }
5434
+ /** Whether files in a folder (`null`: the top level) are private: it or a folder above is. */
5435
+ privateIn(rows, id) {
5436
+ const seen = /* @__PURE__ */ new Set();
5437
+ let current = id === null ? void 0 : rows.get(String(id));
5438
+ while (current && !seen.has(String(current.id))) {
5439
+ if (current.private) return true;
5440
+ seen.add(String(current.id));
5441
+ current = current.parent === null ? void 0 : rows.get(String(current.parent));
5442
+ }
5443
+ return false;
5444
+ }
5445
+ /** Whether files put in this folder are private. */
5446
+ async isPrivate(id) {
5447
+ if (!this.enabled || id === null) return false;
5448
+ return this.privateIn(await this.rows(), id);
5449
+ }
5450
+ /** The folder and every folder inside it. */
5451
+ within(rows, ids) {
5452
+ const out = new Set(ids.map(String));
5453
+ let grew = true;
5454
+ while (grew) {
5455
+ grew = false;
5456
+ for (const row of rows.values())
5457
+ if (row.parent !== null && out.has(String(row.parent)) && !out.has(String(row.id))) {
5458
+ out.add(String(row.id));
5459
+ grew = true;
5460
+ }
5461
+ }
5462
+ return [...rows.values()].filter((r) => out.has(String(r.id))).map((r) => r.id);
5463
+ }
5464
+ /** Folders whose privacy follows this one: it and those inside, with their privacy now. */
5465
+ async subtree(id) {
5466
+ const rows = await this.fresh();
5467
+ return this.within(rows, [id]).map((f) => ({ id: f, private: this.privateIn(rows, f) }));
5468
+ }
5469
+ /**
5470
+ * The privacy each folder of a subtree would have after a change to its top folder: made
5471
+ * private or public, moved, or (as `private: false`) deleted with its contents moving up.
5472
+ */
5473
+ async privacyAfter(id, change) {
5474
+ const rows = new Map(await this.fresh());
5475
+ const row = rows.get(String(id));
5476
+ if (!row) return [];
5477
+ rows.set(String(id), {
5478
+ ...row,
5479
+ ...change.private !== void 0 ? { private: change.private } : {},
5480
+ ...change.parent !== void 0 ? { parent: change.parent } : {}
5481
+ });
5482
+ return this.within(rows, [id]).map((f) => ({ id: f, private: this.privateIn(rows, f) }));
5483
+ }
5484
+ /**
5485
+ * The folder with this key (`folder: 'banners'` on upload fields), made at the top level when
5486
+ * there is none yet.
5487
+ */
5488
+ async keyed(key2) {
5489
+ const find = async () => [...(await this.rows()).values()].find((r) => r.key === key2);
5490
+ let found = await find();
5491
+ if (!found) {
5492
+ this.invalidate();
5493
+ found = await find();
5494
+ }
5495
+ if (found) return found.id;
5496
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5497
+ try {
5498
+ const doc = await this.db.create({
5499
+ collection: MEDIA_FOLDERS,
5500
+ data: {
5501
+ name: key2,
5502
+ key: key2,
5503
+ parent: null,
5504
+ permissions: null,
5505
+ private: false,
5506
+ createdAt: now,
5507
+ updatedAt: now
5508
+ }
5509
+ });
5510
+ this.invalidate();
5511
+ return doc.id;
5512
+ } catch (error) {
5513
+ this.invalidate();
5514
+ const again = await find();
5515
+ if (again) return again.id;
5516
+ throw error;
5517
+ }
5518
+ }
5519
+ /** Whether a file in `folder` is in the folder with this key, or below it. */
5520
+ async inKeyed(key2, folder) {
5521
+ if (folder === null) return false;
5522
+ const rows = await this.rows();
5523
+ const root = [...rows.values()].find((r) => r.key === key2);
5524
+ return !!root && this.within(rows, [root.id]).some((id) => String(id) === String(folder));
5525
+ }
5341
5526
  /** The user's level in a folder (`null`: the top level). */
5342
5527
  async level(user, id) {
5343
5528
  if (!this.applies(user)) return "manage";
@@ -5348,7 +5533,15 @@ var MediaFolders = class {
5348
5533
  * they add none (no folder limits this user).
5349
5534
  */
5350
5535
  async where(user, collection2, operation) {
5351
- if (collection2 !== MEDIA && collection2 !== MEDIA_FOLDERS || !this.applies(user)) return true;
5536
+ if (!this.enabled || collection2 !== MEDIA && collection2 !== MEDIA_FOLDERS) return true;
5537
+ if (!user) return collection2 === MEDIA ? PUBLIC_FILES : true;
5538
+ const limited = keyFolders(user);
5539
+ if (limited) {
5540
+ const ids = this.within(await this.rows(), limited);
5541
+ if (collection2 === MEDIA_FOLDERS) return { id: { in: ids } };
5542
+ return operation === "create" ? true : { folder: { in: ids } };
5543
+ }
5544
+ if (!this.applies(user)) return true;
5352
5545
  if (operation === "create") return true;
5353
5546
  const need = RANK[needed(collection2, operation)];
5354
5547
  const rows = await this.rows();
@@ -5365,6 +5558,13 @@ var MediaFolders = class {
5365
5558
  }
5366
5559
  /** Refuses putting a file (`media`) or a subfolder in a folder the user may not. */
5367
5560
  async checkTarget(user, collection2, folder) {
5561
+ const limited = keyFolders(user);
5562
+ if (limited) {
5563
+ const ids = this.within(await this.rows(), limited).map(String);
5564
+ if (folder === null || !ids.includes(String(folder)))
5565
+ throw new ForbiddenError("This API key may not add to this folder");
5566
+ return;
5567
+ }
5368
5568
  if (!this.applies(user)) return;
5369
5569
  const need = collection2 === MEDIA ? "edit" : "manage";
5370
5570
  if (RANK[await this.level(user, folder)] < RANK[need])
@@ -5729,6 +5929,7 @@ async function createEasyCMS(config, options = {}) {
5729
5929
  const cwd = options.cwd ?? process.cwd();
5730
5930
  const storage = resolved3.upload.storage ?? localStorage({ dir: resolved3.upload.dir });
5731
5931
  await storage.init?.({ cwd });
5932
+ if (resolved3.upload.privateStorage) await resolved3.upload.privateStorage.init?.({ cwd });
5732
5933
  const db = await resolved3.db.init({
5733
5934
  config: resolved3,
5734
5935
  cwd,
@@ -5757,6 +5958,11 @@ var EasyCMS = class {
5757
5958
  auth;
5758
5959
  /** Where uploaded files are stored. */
5759
5960
  storage;
5961
+ /**
5962
+ * Where files in private folders are stored (`upload.privateStorage`, else `storage` when it has
5963
+ * no public URLs); `null` when private folders can't be used.
5964
+ */
5965
+ privateStorage;
5760
5966
  /** Project root: relative paths in the config (e.g. `admin.modules`) start here. */
5761
5967
  cwd;
5762
5968
  /** Roles and their permissions (`auth.rbac`, Settings → Roles). */
@@ -5768,11 +5974,12 @@ var EasyCMS = class {
5768
5974
  versions;
5769
5975
  webhooks;
5770
5976
  mailer;
5771
- constructor(config, db, logger = consoleLogger, storage = localStorage({ dir: config.upload.dir }), cwd = process.cwd()) {
5977
+ constructor(config, db, logger = consoleLogger, storage = localStorage({ dir: config.upload.dir }), cwd = process.cwd(), privateStorage) {
5772
5978
  this.config = config;
5773
5979
  this.db = db;
5774
5980
  this.logger = logger;
5775
5981
  this.storage = storage;
5982
+ this.privateStorage = privateStorage !== void 0 ? privateStorage : config.upload.privateStorage ?? (typeof storage.url === "function" ? null : storage);
5776
5983
  this.cwd = cwd;
5777
5984
  this.versions = new VersionStore(db);
5778
5985
  this.roles = new Roles(
@@ -5873,12 +6080,7 @@ var EasyCMS = class {
5873
6080
  async upload(file, data = {}, options = {}) {
5874
6081
  const config = this.collection(MEDIA);
5875
6082
  const guard = guardOf(options);
5876
- if (guard.enforce) {
5877
- await this.checkGrant(guard, { collection: MEDIA }, "create");
5878
- const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data });
5879
- if (allowed2 !== true) throw deny(guard.user);
5880
- await this.folders.checkTarget(guard.user, MEDIA, folderOf(MEDIA, data));
5881
- }
6083
+ await this.checkUpload(guard, data, folderOf(MEDIA, data));
5882
6084
  const { maxFileSize, mimeTypes, imageSizes } = this.config.upload;
5883
6085
  if (file.data.byteLength > maxFileSize) {
5884
6086
  throw new PayloadTooLargeError(`File is larger than ${maxFileSize} bytes`);
@@ -5895,17 +6097,27 @@ var EasyCMS = class {
5895
6097
  ]);
5896
6098
  }
5897
6099
  const random2 = randomBytes5(4).toString("hex");
5898
- const filename = storageKey(file.name, mimeType, random2);
6100
+ const isPrivate = await this.folders.isPrivate(folderOf(MEDIA, data));
6101
+ const storage = this.storageFor(isPrivate);
6102
+ const filename = withPrivacy(storageKey(file.name, mimeType, random2), isPrivate);
5899
6103
  const stored = [];
5900
6104
  try {
5901
- await this.storage.put(filename, file.data, { contentType: mimeType });
6105
+ await storage.put(filename, file.data, { contentType: mimeType });
5902
6106
  stored.push(filename);
5903
6107
  const dimensions = imageDimensions(file.data, mimeType);
5904
- const sizes = await this.resizeImage(file.data, mimeType, filename, imageSizes, stored);
6108
+ const sizes = await this.resizeImage(
6109
+ file.data,
6110
+ mimeType,
6111
+ filename,
6112
+ imageSizes,
6113
+ stored,
6114
+ storage
6115
+ );
5905
6116
  const doc = await this.createDocument(
5906
6117
  config,
5907
6118
  {
5908
6119
  ...data,
6120
+ ...this.folders.enabled ? { private: isPrivate } : {},
5909
6121
  filename,
5910
6122
  originalName: file.name.slice(0, 255),
5911
6123
  mimeType,
@@ -5920,11 +6132,155 @@ var EasyCMS = class {
5920
6132
  );
5921
6133
  return doc;
5922
6134
  } catch (error) {
5923
- for (const key2 of stored) await this.storage.delete(key2).catch(() => {
6135
+ for (const key2 of stored) await storage.delete(key2).catch(() => {
5924
6136
  });
5925
6137
  throw error;
5926
6138
  }
5927
6139
  }
6140
+ /**
6141
+ * Starts an upload straight to the storage, for files too large to send through the server
6142
+ * (`POST <api>/media/uploads`): checks who uploads what where, as `upload()` does, and returns
6143
+ * a signed ticket and where to send the file. `upload` is `null` when the storage can't take
6144
+ * files directly: send it to `upload()` instead. Then `completeUpload(ticket)`.
6145
+ */
6146
+ async createUpload(file, data = {}, options = {}) {
6147
+ const guard = guardOf(options);
6148
+ const folder = folderOf(MEDIA, data);
6149
+ await this.checkUpload(guard, data, folder);
6150
+ const { maxFileSize, mimeTypes } = this.config.upload;
6151
+ const size = Number(file.size);
6152
+ if (!Number.isInteger(size) || size <= 0)
6153
+ throw new ValidationError(MEDIA, [{ field: "size", message: "must be the size in bytes" }]);
6154
+ if (size > maxFileSize)
6155
+ throw new PayloadTooLargeError(`File is larger than ${maxFileSize} bytes`);
6156
+ const name = String(file.name ?? "").slice(0, 255);
6157
+ const type = typeFromName(name) ?? file.type ?? "";
6158
+ if (!EXTENSIONS[type] || !mimeAllowed(type, mimeTypes))
6159
+ throw new ValidationError(MEDIA, [
6160
+ {
6161
+ field: "file",
6162
+ message: `file type ${type || "unknown"} is not allowed (allowed: ${mimeTypes.join(", ")})`
6163
+ }
6164
+ ]);
6165
+ const isPrivate = await this.folders.isPrivate(folder);
6166
+ const storage = this.storageFor(isPrivate);
6167
+ const key2 = withPrivacy(storageKey(name, type, randomBytes5(4).toString("hex")), isPrivate);
6168
+ const upload = storage.uploadURL ? await storage.uploadURL(key2, { contentType: type, size, expiresIn: UPLOAD_URL_SECONDS }) : null;
6169
+ const ticket = this.sign("upload", {
6170
+ key: key2,
6171
+ private: isPrivate,
6172
+ name,
6173
+ size,
6174
+ type,
6175
+ user: guard.user?.id ?? null,
6176
+ expires: Math.floor(Date.now() / 1e3) + UPLOAD_TICKET_SECONDS,
6177
+ data
6178
+ });
6179
+ return { ticket, upload };
6180
+ }
6181
+ /**
6182
+ * Finishes an upload made with `createUpload()` (`POST <api>/media/uploads/complete`): checks
6183
+ * the file in the storage like any upload (its size, and its type from its contents), then
6184
+ * makes its media document. A file that fails is deleted.
6185
+ */
6186
+ async completeUpload(ticket, options = {}) {
6187
+ const guard = guardOf(options);
6188
+ const t = this.verify("upload", ticket);
6189
+ if (!t || t.expires < Date.now() / 1e3)
6190
+ throw new ValidationError(MEDIA, [
6191
+ { field: "ticket", message: "is not valid or has expired" }
6192
+ ]);
6193
+ if (guard.enforce && String(guard.user?.id ?? null) !== String(t.user)) throw deny(guard.user);
6194
+ await this.checkUpload(guard, t.data, folderOf(MEDIA, t.data));
6195
+ if (await this.db.count({ collection: MEDIA, where: { filename: { equals: t.key } } }) > 0)
6196
+ throw new ValidationError(MEDIA, [{ field: "ticket", message: "was used already" }]);
6197
+ const storage = this.storageFor(t.private);
6198
+ const start = storage.getStart ? await storage.getStart(t.key, SNIFF_BYTES) : await storage.get(t.key);
6199
+ if (!start) throw new ValidationError(MEDIA, [{ field: "file", message: "was not uploaded" }]);
6200
+ const refuse = async (message2) => {
6201
+ await storage.delete(t.key).catch(() => {
6202
+ });
6203
+ throw new ValidationError(MEDIA, [{ field: "file", message: message2 }]);
6204
+ };
6205
+ if (start.size !== t.size) await refuse(`is ${start.size} bytes, not the ${t.size} announced`);
6206
+ if (start.size > this.config.upload.maxFileSize)
6207
+ await refuse(`is larger than ${this.config.upload.maxFileSize} bytes`);
6208
+ const mimeType = sniffMimeType(start.body.subarray(0, SNIFF_BYTES), t.name);
6209
+ if (mimeType !== t.type)
6210
+ await refuse(`contains ${mimeType ?? "an unknown type"}, not ${t.type}`);
6211
+ const stored = [];
6212
+ try {
6213
+ let dimensions;
6214
+ let sizes = {};
6215
+ if (t.type.startsWith("image/")) {
6216
+ const whole = await storage.get(t.key);
6217
+ if (whole) {
6218
+ dimensions = imageDimensions(whole.body, t.type);
6219
+ sizes = await this.resizeImage(
6220
+ whole.body,
6221
+ t.type,
6222
+ t.key,
6223
+ this.config.upload.imageSizes,
6224
+ stored,
6225
+ storage
6226
+ );
6227
+ }
6228
+ }
6229
+ const doc = await this.createDocument(
6230
+ this.collection(MEDIA),
6231
+ {
6232
+ ...t.data,
6233
+ ...this.folders.enabled ? { private: t.private } : {},
6234
+ filename: t.key,
6235
+ originalName: t.name,
6236
+ mimeType: t.type,
6237
+ filesize: start.size,
6238
+ width: dimensions?.width ?? null,
6239
+ height: dimensions?.height ?? null,
6240
+ sizes
6241
+ },
6242
+ { ...options, overrideAccess: true },
6243
+ guard
6244
+ );
6245
+ return doc;
6246
+ } catch (error) {
6247
+ for (const key2 of [t.key, ...stored]) await storage.delete(key2).catch(() => {
6248
+ });
6249
+ throw error;
6250
+ }
6251
+ }
6252
+ /** Who may upload, and into which folder: `upload()` and direct uploads. */
6253
+ async checkUpload(guard, data, folder) {
6254
+ if (!guard.enforce) return;
6255
+ await this.checkGrant(guard, { collection: MEDIA }, "create");
6256
+ const allowed2 = await evaluateAccess(this.collection(MEDIA).access?.create, {
6257
+ user: guard.user,
6258
+ data
6259
+ });
6260
+ if (allowed2 !== true) throw deny(guard.user);
6261
+ await this.folders.checkTarget(guard.user, MEDIA, folder);
6262
+ }
6263
+ /** A value with its HMAC (`secret`), for `purpose` only: `<base64url JSON>.<signature>`. */
6264
+ sign(purpose, value) {
6265
+ const payload = Buffer.from(JSON.stringify(value)).toString("base64url");
6266
+ const mac = createHmac5("sha256", this.config.secret).update(`${purpose}:${payload}`);
6267
+ return `${payload}.${mac.digest("base64url")}`;
6268
+ }
6269
+ /** The value of a token made by `sign`, or `undefined` when it isn't genuine. */
6270
+ verify(purpose, token) {
6271
+ const [payload, signature] = String(token).split(".");
6272
+ if (!payload || !signature) return void 0;
6273
+ const expected = Buffer.from(
6274
+ createHmac5("sha256", this.config.secret).update(`${purpose}:${payload}`).digest("base64url")
6275
+ );
6276
+ const given = Buffer.from(signature);
6277
+ if (expected.length !== given.length || !timingSafeEqual4(expected, given)) return void 0;
6278
+ try {
6279
+ return JSON.parse(Buffer.from(payload, "base64url").toString());
6280
+ } catch {
6281
+ return void 0;
6282
+ }
6283
+ }
5928
6284
  /**
5929
6285
  * Downloads a file from an `http(s)` link and uploads it like `upload()`. Private network
5930
6286
  * addresses are refused unless `upload.fromURL.allowPrivate`. Calls on behalf of a user (the
@@ -5995,9 +6351,159 @@ var EasyCMS = class {
5995
6351
  }
5996
6352
  /** Public URL of a stored file. */
5997
6353
  mediaURL(key2) {
6354
+ const base = `${this.config.serverURL?.replace(/\/+$/, "") ?? ""}${this.config.routes.api}`;
6355
+ if (isPrivateKey(key2)) return `${base}/media/private/${encodeURIComponent(key2)}`;
5998
6356
  const custom = this.storage.url?.(key2);
5999
6357
  if (custom) return custom;
6000
- return `${this.config.serverURL?.replace(/\/+$/, "") ?? ""}${this.config.routes.api}/media/file/${encodeURIComponent(key2)}`;
6358
+ return `${base}/media/file/${encodeURIComponent(key2)}`;
6359
+ }
6360
+ /**
6361
+ * A link to a private file that works without signing in until it expires: `expiresIn` in
6362
+ * seconds or as `'30m'`, `'1h'` (the default), `'7d'` (the most). Public files get their usual
6363
+ * URL. `size`: one of the file's resized copies. Links stop working when `secret` changes or
6364
+ * the file stops being private.
6365
+ */
6366
+ signedMediaURL(doc, options = {}) {
6367
+ const sizes = doc.sizes ?? {};
6368
+ const key2 = options.size ? sizes[options.size]?.filename : doc.filename;
6369
+ if (typeof key2 !== "string" || !key2)
6370
+ throw new QueryError(
6371
+ options.size ? `The file has no "${options.size}" size` : "The document is not a file"
6372
+ );
6373
+ if (!isPrivateKey(key2)) return this.mediaURL(key2);
6374
+ const seconds = durationSeconds(options.expiresIn ?? "1h");
6375
+ const expires = Math.floor(Date.now() / 1e3) + seconds;
6376
+ return `${this.mediaURL(key2)}?expires=${expires}&signature=${this.mediaSignature(key2, expires)}`;
6377
+ }
6378
+ /** Whether a signed link to a private file is genuine and not expired. */
6379
+ verifyMediaSignature(key2, expires, signature) {
6380
+ const at = Number(expires);
6381
+ if (!signature || !Number.isInteger(at) || at < Date.now() / 1e3) return false;
6382
+ const expected = Buffer.from(this.mediaSignature(key2, at));
6383
+ const given = Buffer.from(signature);
6384
+ return expected.length === given.length && timingSafeEqual4(expected, given);
6385
+ }
6386
+ mediaSignature(key2, expires) {
6387
+ return createHmac5("sha256", this.config.secret).update(`media:${key2}:${expires}`).digest("base64url");
6388
+ }
6389
+ /**
6390
+ * How many documents use any of these files in a top-level upload field (rich text and fields
6391
+ * inside groups, arrays and blocks are not counted). Drafts count too.
6392
+ */
6393
+ async mediaUsage(ids) {
6394
+ if (ids.length === 0) return 0;
6395
+ let count = 0;
6396
+ for (const config of this.config.collections) {
6397
+ if (INTERNAL_COLLECTIONS.has(config.slug)) continue;
6398
+ for (const field of config.fields) {
6399
+ if (field.type !== "upload") continue;
6400
+ count += await this.db.count({
6401
+ collection: config.slug,
6402
+ where: { [field.name]: { in: [...ids] } }
6403
+ });
6404
+ }
6405
+ }
6406
+ return count;
6407
+ }
6408
+ /** The storage for public files, or for private ones. */
6409
+ storageFor(isPrivate) {
6410
+ if (!isPrivate) return this.storage;
6411
+ if (!this.privateStorage)
6412
+ throw new EasyCMSError(
6413
+ "Private folders need upload.privateStorage: the storage has public URLs, so private files must be kept elsewhere",
6414
+ 500
6415
+ );
6416
+ return this.privateStorage;
6417
+ }
6418
+ /**
6419
+ * Copies a file and its resized copies to the other storage under their new names (`.private`
6420
+ * added or removed). Returns the fields to save, and `cleanUp` to delete the old copies once
6421
+ * the document points to the new ones.
6422
+ */
6423
+ async relocate(doc, toPrivate) {
6424
+ const from = this.storageFor(doc.private === true);
6425
+ const to = this.storageFor(toPrivate);
6426
+ const contentType = String(doc.mimeType ?? "application/octet-stream");
6427
+ const filename = withPrivacy(String(doc.filename), toPrivate);
6428
+ const old = [String(doc.filename)];
6429
+ const copy = async (source, target) => {
6430
+ const file = await from.get(source);
6431
+ if (file) await to.put(target, file.body, { contentType });
6432
+ };
6433
+ await copy(String(doc.filename), filename);
6434
+ const sizes = {};
6435
+ for (const [name, size] of Object.entries(
6436
+ doc.sizes ?? {}
6437
+ )) {
6438
+ const key2 = sizeKey(filename, name);
6439
+ await copy(String(size.filename), key2);
6440
+ old.push(String(size.filename));
6441
+ sizes[name] = { ...size, filename: key2 };
6442
+ }
6443
+ return {
6444
+ patch: { filename, sizes, private: toPrivate },
6445
+ cleanUp: async () => {
6446
+ for (const key2 of old) await from.delete(key2).catch(() => {
6447
+ });
6448
+ }
6449
+ };
6450
+ }
6451
+ /** A folder's name, parent and permissions; private only with somewhere private to keep files. */
6452
+ async validateFolder(data, self) {
6453
+ await this.folders.validate(data, self);
6454
+ if (data.private === true && !this.privateStorage)
6455
+ throw new ValidationError(MEDIA_FOLDERS, [
6456
+ {
6457
+ field: "private",
6458
+ message: "needs upload.privateStorage: the storage gives files public URLs, so private files must be kept elsewhere"
6459
+ }
6460
+ ]);
6461
+ }
6462
+ /**
6463
+ * Refuses a change that would move more than 200 files between storages in one request: they
6464
+ * are copied one by one, which could outlast a serverless function.
6465
+ */
6466
+ async checkMoveLimit(folders) {
6467
+ let count = 0;
6468
+ for (const f of folders)
6469
+ count += await this.db.count({
6470
+ collection: MEDIA,
6471
+ where: andWhere(
6472
+ { folder: { equals: f.id } },
6473
+ f.private ? PUBLIC_FILES : { private: { equals: true } }
6474
+ )
6475
+ });
6476
+ if (count > MAX_FILES_TO_MOVE)
6477
+ throw new ValidationError(MEDIA_FOLDERS, [
6478
+ {
6479
+ field: "private",
6480
+ message: `${count} files would move between public and private storage; move at most ${MAX_FILES_TO_MOVE} at a time (folder by folder)`
6481
+ }
6482
+ ]);
6483
+ }
6484
+ /** After a folder became private or public (or moved): its files follow, in every subfolder. */
6485
+ async settlePrivacy(folder) {
6486
+ for (const f of await this.folders.subtree(folder))
6487
+ await this.settleFiles({ folder: { equals: f.id } }, f.private);
6488
+ }
6489
+ /** Files at the top level are public. */
6490
+ async settleTopLevel() {
6491
+ await this.settleFiles({ folder: { exists: false } }, false);
6492
+ }
6493
+ async settleFiles(where, isPrivate) {
6494
+ const found = await this.db.find({
6495
+ collection: MEDIA,
6496
+ where: andWhere(where, isPrivate ? PUBLIC_FILES : { private: { equals: true } }),
6497
+ sort: [],
6498
+ limit: 0,
6499
+ page: 1
6500
+ });
6501
+ for (const doc of found.docs) {
6502
+ const moved = await this.relocate(doc, isPrivate);
6503
+ const { id, ...rest } = doc;
6504
+ await this.db.update({ collection: MEDIA, id, data: { ...rest, ...moved.patch } });
6505
+ await moved.cleanUp();
6506
+ }
6001
6507
  }
6002
6508
  async update(collection2, id, data, options = {}) {
6003
6509
  return await this.updateDocument(collection2, id, asObject(data, collection2), options);
@@ -6279,13 +6785,35 @@ var EasyCMS = class {
6279
6785
  }
6280
6786
  if (config.slug === USERS) await this.guardLastAdmin(parsed, existing, prepared);
6281
6787
  if (password !== void 0) prepared.passwordHash = await hashPassword(password);
6282
- if (collection2 === MEDIA_FOLDERS) await this.folders.validate(prepared, parsed);
6788
+ if (collection2 === MEDIA_FOLDERS) {
6789
+ await this.validateFolder(prepared, parsed);
6790
+ if (prepared.private !== existing.private || String(prepared.parent ?? null) !== String(existing.parent ?? null))
6791
+ await this.checkMoveLimit(
6792
+ await this.folders.privacyAfter(parsed, {
6793
+ private: prepared.private === true,
6794
+ parent: prepared.parent ?? null
6795
+ })
6796
+ );
6797
+ }
6798
+ let moved;
6799
+ if (collection2 === MEDIA && this.folders.enabled) {
6800
+ const toPrivate = await this.folders.isPrivate(folderOf(MEDIA, prepared));
6801
+ if (toPrivate !== (existing.private === true)) {
6802
+ moved = await this.relocate(existing, toPrivate);
6803
+ Object.assign(prepared, moved.patch);
6804
+ }
6805
+ }
6283
6806
  const doc = await this.db.update({
6284
6807
  collection: collection2,
6285
6808
  id: parsed,
6286
6809
  data: { ...prepared, createdAt: existing.createdAt, updatedAt: now }
6287
6810
  });
6288
- if (collection2 === MEDIA_FOLDERS) this.folders.invalidate();
6811
+ if (moved) await moved.cleanUp();
6812
+ if (collection2 === MEDIA_FOLDERS) {
6813
+ this.folders.invalidate();
6814
+ if (prepared.private !== existing.private || String(prepared.parent ?? null) !== String(existing.parent ?? null))
6815
+ await this.settlePrivacy(parsed);
6816
+ }
6289
6817
  if (!live) await this.saveVersion(config, collectionParent(collection2), parsed, doc, guard);
6290
6818
  if (password !== void 0) await this.auth.revokeSessions(parsed);
6291
6819
  await this.notify(config.hooks?.afterChange, "afterChange", config.slug, {
@@ -6328,9 +6856,17 @@ var EasyCMS = class {
6328
6856
  await this.auth.revokeSessions(parsed);
6329
6857
  await this.auth.sso.forget(parsed);
6330
6858
  }
6331
- if (collection2 === MEDIA_FOLDERS) await this.folders.release(existing);
6859
+ if (collection2 === MEDIA_FOLDERS) {
6860
+ await this.checkMoveLimit(await this.folders.privacyAfter(parsed, { private: false }));
6861
+ await this.folders.release(existing);
6862
+ }
6332
6863
  await this.db.delete({ collection: collection2, id: parsed });
6333
- if (collection2 === MEDIA_FOLDERS) this.folders.invalidate();
6864
+ if (collection2 === MEDIA_FOLDERS) {
6865
+ this.folders.invalidate();
6866
+ const parent = existing.parent ?? null;
6867
+ if (parent !== null) await this.settlePrivacy(parent);
6868
+ else await this.settleTopLevel();
6869
+ }
6334
6870
  if (versionLimit(config)) await this.versions.deleteAll(collectionParent(collection2), parsed);
6335
6871
  if (config.schedule) {
6336
6872
  for (const job of await this.pendingJobs(collection2, parsed))
@@ -7074,7 +7610,7 @@ var EasyCMS = class {
7074
7610
  prepared
7075
7611
  );
7076
7612
  if (password !== void 0) prepared.passwordHash = await hashPassword(password);
7077
- if (collection2 === MEDIA_FOLDERS) await this.folders.validate(prepared, void 0);
7613
+ if (collection2 === MEDIA_FOLDERS) await this.validateFolder(prepared, void 0);
7078
7614
  const now = (/* @__PURE__ */ new Date()).toISOString();
7079
7615
  const doc = await this.db.create({
7080
7616
  collection: collection2,
@@ -7234,7 +7770,7 @@ var EasyCMS = class {
7234
7770
  }
7235
7771
  }
7236
7772
  /** Writes resized copies with sharp, when installed and configured. */
7237
- async resizeImage(data, mimeType, filename, sizes, stored) {
7773
+ async resizeImage(data, mimeType, filename, sizes, stored, storage = this.storage) {
7238
7774
  if (sizes.length === 0 || !["image/png", "image/jpeg", "image/webp", "image/avif"].includes(mimeType))
7239
7775
  return {};
7240
7776
  const sharp = await loadSharp();
@@ -7252,8 +7788,8 @@ var EasyCMS = class {
7252
7788
  fit: size.fit ?? "cover",
7253
7789
  withoutEnlargement: true
7254
7790
  }).toBuffer({ resolveWithObject: true });
7255
- const key2 = filename.replace(/\.([^.]+)$/, `-${size.name}.$1`);
7256
- await this.storage.put(key2, new Uint8Array(out), { contentType: mimeType });
7791
+ const key2 = sizeKey(filename, size.name);
7792
+ await storage.put(key2, new Uint8Array(out), { contentType: mimeType });
7257
7793
  stored.push(key2);
7258
7794
  result[size.name] = {
7259
7795
  filename: key2,
@@ -7421,6 +7957,14 @@ var EasyCMS = class {
7421
7957
  message: images ? `must be an image (${doc.filename ?? ref.id} is ${type || "not one"})` : `must be ${ref.mimeTypes.join(", ")} (${doc.filename ?? ref.id} is ${type || "unknown"})`
7422
7958
  });
7423
7959
  }
7960
+ if (ref.folderOnly && this.folders.enabled && !await this.folders.inKeyed(
7961
+ ref.folderOnly,
7962
+ doc.folder ?? null
7963
+ ))
7964
+ errors.push({
7965
+ field: ref.field,
7966
+ message: `must be a file from the "${ref.folderOnly}" folder (${doc.filename ?? ref.id} is not)`
7967
+ });
7424
7968
  }
7425
7969
  }
7426
7970
  return errors;
@@ -7487,6 +8031,19 @@ function folderOf(collection2, data) {
7487
8031
  const value = data[collection2 === MEDIA ? "folder" : "parent"];
7488
8032
  return value === void 0 || value === "" ? null : value;
7489
8033
  }
8034
+ var MAX_LINK = 7 * 24 * 60 * 60;
8035
+ var UPLOAD_URL_SECONDS = 15 * 60;
8036
+ var UPLOAD_TICKET_SECONDS = 24 * 60 * 60;
8037
+ var SNIFF_BYTES = 64 * 1024;
8038
+ var MAX_FILES_TO_MOVE = 200;
8039
+ function durationSeconds(value) {
8040
+ const match = typeof value === "string" ? /^(\d+)\s*([smhd])$/.exec(value.trim()) : null;
8041
+ const seconds = typeof value === "number" ? value : match ? Number(match[1]) * { s: 1, m: 60, h: 3600, d: 86400 }[match[2]] : Number.NaN;
8042
+ if (!Number.isFinite(seconds) || seconds <= 0)
8043
+ throw new QueryError(`expiresIn must be seconds or like '30m', '1h', '7d' (got ${value})`);
8044
+ if (seconds > MAX_LINK) throw new QueryError("expiresIn can be at most 7 days");
8045
+ return Math.floor(seconds);
8046
+ }
7490
8047
 
7491
8048
  // src/rest/admin-modules.ts
7492
8049
  import { createHash as createHash3 } from "crypto";
@@ -7735,7 +8292,7 @@ function componentRef(component) {
7735
8292
  async function allowed(access, user) {
7736
8293
  return await evaluateAccess(access, { user }) !== false;
7737
8294
  }
7738
- async function serializeFields(fields, checks, localized) {
8295
+ async function serializeFields(fields, checks, localized, folderId) {
7739
8296
  const { update: update2 } = checks;
7740
8297
  const out = [];
7741
8298
  for (const field of fields) {
@@ -7788,11 +8345,13 @@ async function serializeFields(fields, checks, localized) {
7788
8345
  if (field.minRows !== void 0) f.minRows = field.minRows;
7789
8346
  if (field.maxRows !== void 0) f.maxRows = field.maxRows;
7790
8347
  if (field.mimeTypes) f.mimeTypes = expandMimeTypes(field.mimeTypes);
8348
+ if (field.folder && folderId)
8349
+ f.folder = { id: await folderId(field.folder), only: field.folderOnly === true };
7791
8350
  break;
7792
8351
  case "array":
7793
8352
  if (field.minRows !== void 0) f.minRows = field.minRows;
7794
8353
  if (field.maxRows !== void 0) f.maxRows = field.maxRows;
7795
- f.fields = await serializeFields(field.fields, checks, localized);
8354
+ f.fields = await serializeFields(field.fields, checks, localized, folderId);
7796
8355
  break;
7797
8356
  case "blocks":
7798
8357
  if (field.minRows !== void 0) f.minRows = field.minRows;
@@ -7801,12 +8360,12 @@ async function serializeFields(fields, checks, localized) {
7801
8360
  field.blocks.map(async (block) => ({
7802
8361
  slug: block.slug,
7803
8362
  ...block.labels ? { labels: block.labels } : {},
7804
- fields: await serializeFields(block.fields, checks, localized)
8363
+ fields: await serializeFields(block.fields, checks, localized, folderId)
7805
8364
  }))
7806
8365
  );
7807
8366
  break;
7808
8367
  case "group":
7809
- f.fields = await serializeFields(field.fields, checks, localized);
8368
+ f.fields = await serializeFields(field.fields, checks, localized, folderId);
7810
8369
  break;
7811
8370
  }
7812
8371
  out.push(f);
@@ -7825,7 +8384,12 @@ async function may(cms, access, user, target, operation) {
7825
8384
  }
7826
8385
  async function collection(cms, config, user, localized, roles) {
7827
8386
  const target = { collection: config.slug };
7828
- const fields = await serializeFields(config.fields, await checkers(cms, user), localized);
8387
+ const fields = await serializeFields(
8388
+ config.fields,
8389
+ await checkers(cms, user),
8390
+ localized,
8391
+ folderIds(cms)
8392
+ );
7829
8393
  if (config.slug === USERS && cms.roles.enabled) {
7830
8394
  const role = fields.find((f) => f.name === "role");
7831
8395
  if (role) {
@@ -7870,7 +8434,7 @@ async function global(cms, config, user, localized) {
7870
8434
  versions: Boolean(config.versions),
7871
8435
  preview: typeof config.preview === "function",
7872
8436
  schedule: config.schedule === true,
7873
- fields: await serializeFields(config.fields, checks, localized),
8437
+ fields: await serializeFields(config.fields, checks, localized, folderIds(cms)),
7874
8438
  permissions: {
7875
8439
  read: await may(cms, config.access?.read, user, target, "read"),
7876
8440
  update: await may(cms, config.access?.update, user, target, "update"),
@@ -7915,7 +8479,13 @@ async function adminSchema(cms, user, origin) {
7915
8479
  maxFileSize: cms.config.upload.maxFileSize,
7916
8480
  mimeTypes: expandMimeTypes(cms.config.upload.mimeTypes)
7917
8481
  },
7918
- ...cms.folders.enabled ? { folders: { permissions: admin && cms.roles.enabled } } : {},
8482
+ ...cms.folders.enabled ? {
8483
+ folders: {
8484
+ permissions: admin && cms.roles.enabled,
8485
+ // Admins can make folders private when there is somewhere private to keep files.
8486
+ private: admin && cms.privateStorage !== null
8487
+ }
8488
+ } : {},
7919
8489
  views,
7920
8490
  rbac: cms.roles.enabled,
7921
8491
  providers: cms.auth.sso.providers(),
@@ -7962,9 +8532,12 @@ async function widgets(cms, user) {
7962
8532
  }
7963
8533
  return out;
7964
8534
  }
8535
+ function folderIds(cms) {
8536
+ return cms.folders.enabled ? (key2) => cms.folders.keyed(key2) : void 0;
8537
+ }
7965
8538
 
7966
8539
  // src/version.ts
7967
- var VERSION = true ? "0.39.0" : "0.0.0-dev";
8540
+ var VERSION = true ? "0.41.0" : "0.0.0-dev";
7968
8541
 
7969
8542
  // src/rest/admin-status.ts
7970
8543
  var DAY2 = 864e5;
@@ -8533,6 +9106,12 @@ async function route(cms, ctx, method, segments, options) {
8533
9106
  if (parsed === void 0) throw new HttpError("Not found", 404);
8534
9107
  return { body: await cms.roles.owned(parsed) };
8535
9108
  }
9109
+ if (first === "admin" && second === "media-usage" && segments.length === 2) {
9110
+ if (!ctx.user || ctx.user.apiKey) throw new UnauthorizedError();
9111
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
9112
+ const ids = (ctx.url.searchParams.get("ids") ?? "").split(",").map((id2) => parseId(id2)).filter((id2) => id2 !== void 0);
9113
+ return { body: { count: await cms.mediaUsage(ids) } };
9114
+ }
8536
9115
  if (first === "admin" && second === "roles") {
8537
9116
  if (!ctx.user) throw new UnauthorizedError();
8538
9117
  if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
@@ -8679,6 +9258,34 @@ async function route(cms, ctx, method, segments, options) {
8679
9258
  if (method !== "GET" && method !== "HEAD") throw methodNotAllowed(ctx, "GET, HEAD");
8680
9259
  return { body: await serveFile(cms, third, method === "HEAD") };
8681
9260
  }
9261
+ if (first === MEDIA && second === "private" && third !== void 0 && segments.length === 3) {
9262
+ if (method !== "GET" && method !== "HEAD") throw methodNotAllowed(ctx, "GET, HEAD");
9263
+ return { body: await servePrivateFile(cms, ctx, third, method === "HEAD") };
9264
+ }
9265
+ if (first === MEDIA && second === "uploads" && segments.length <= 3) {
9266
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
9267
+ const body = await readJson(ctx.request);
9268
+ if (third === "complete" && segments.length === 3) {
9269
+ if (typeof body.ticket !== "string" || body.ticket === "")
9270
+ throw new ValidationError(MEDIA, [{ field: "ticket", message: "is required" }]);
9271
+ return {
9272
+ status: 201,
9273
+ body: await cms.completeUpload(body.ticket, { ...access, ...parseDepth(ctx.url) })
9274
+ };
9275
+ }
9276
+ if (third !== void 0) throw new HttpError("Not found", 404);
9277
+ const { name, size, type, ...data } = body;
9278
+ if (typeof name !== "string" || name === "")
9279
+ throw new ValidationError(MEDIA, [{ field: "name", message: "is required" }]);
9280
+ return {
9281
+ status: 201,
9282
+ body: await cms.createUpload(
9283
+ { name, size: Number(size), ...typeof type === "string" ? { type } : {} },
9284
+ data,
9285
+ access
9286
+ )
9287
+ };
9288
+ }
8682
9289
  if (first === MEDIA && second === void 0 && method === "POST") {
8683
9290
  if ((ctx.request.headers.get("content-type") ?? "").toLowerCase().startsWith("application/json")) {
8684
9291
  const { url, ...data2 } = await readJson(ctx.request);
@@ -8978,6 +9585,48 @@ async function serveFile(cms, key2, head) {
8978
9585
  }
8979
9586
  );
8980
9587
  }
9588
+ async function servePrivateFile(cms, ctx, key2, head) {
9589
+ if (!PRIVATE_KEY.test(key2) || !cms.privateStorage) throw new HttpError("Not found", 404);
9590
+ const query = ctx.url.searchParams;
9591
+ const signed = cms.verifyMediaSignature(key2, query.get("expires"), query.get("signature"));
9592
+ if (!signed) {
9593
+ const token = /-([0-9a-f]{8})\.private[.-]/.exec(key2)?.[1];
9594
+ if (!ctx.user || !token) throw new HttpError("Not found", 404);
9595
+ const found = await cms.find(MEDIA, {
9596
+ where: { filename: { like: `-${token}.private.` } },
9597
+ limit: 5,
9598
+ depth: 0,
9599
+ user: ctx.user,
9600
+ overrideAccess: false
9601
+ });
9602
+ const owns = found.docs.some(
9603
+ (d) => d.filename === key2 || Object.values(d.sizes ?? {}).some(
9604
+ (s) => s.filename === key2
9605
+ )
9606
+ );
9607
+ if (!owns) throw new HttpError("Not found", 404);
9608
+ }
9609
+ const file = await cms.privateStorage.get(key2);
9610
+ if (!file) throw new HttpError("Not found", 404);
9611
+ const type = TYPE_BY_EXTENSION[key2.slice(key2.lastIndexOf(".") + 1)] ?? "application/octet-stream";
9612
+ return new Response(
9613
+ head ? null : file.body,
9614
+ {
9615
+ status: 200,
9616
+ headers: {
9617
+ "content-type": type,
9618
+ "content-length": String(file.size),
9619
+ // Not in shared caches; a short while in the browser.
9620
+ "cache-control": "private, max-age=300",
9621
+ "x-content-type-options": "nosniff",
9622
+ ...type === "application/pdf" ? {} : {
9623
+ "content-security-policy": "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox"
9624
+ },
9625
+ "cross-origin-resource-policy": "cross-origin"
9626
+ }
9627
+ }
9628
+ );
9629
+ }
8981
9630
  async function readUpload(request, maxFileSize) {
8982
9631
  const type = request.headers.get("content-type") ?? "";
8983
9632
  if (!type.toLowerCase().startsWith("multipart/form-data")) {