@easy-cms/core 0.30.0 → 0.32.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 +114 -1
  2. package/dist/index.js +748 -266
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -116,10 +116,10 @@ function cleanPermissions(value, known) {
116
116
  const pick = (group, slugs, operations) => {
117
117
  const out = {};
118
118
  if (typeof group !== "object" || group === null) return out;
119
- for (const [slug, ops] of Object.entries(group)) {
120
- if (!slugs.includes(slug) || API_KEY_EXCLUDED.has(slug) || !Array.isArray(ops)) continue;
119
+ for (const [slug2, ops] of Object.entries(group)) {
120
+ if (!slugs.includes(slug2) || API_KEY_EXCLUDED.has(slug2) || !Array.isArray(ops)) continue;
121
121
  const valid = operations.filter((op) => ops.includes(op));
122
- if (valid.length) out[slug] = valid;
122
+ if (valid.length) out[slug2] = valid;
123
123
  }
124
124
  return out;
125
125
  };
@@ -150,8 +150,8 @@ ${lines.join("\n")}`;
150
150
  };
151
151
  var EasyCMSError = class extends Error {
152
152
  status;
153
- constructor(message, status) {
154
- super(message);
153
+ constructor(message2, status) {
154
+ super(message2);
155
155
  this.name = "EasyCMSError";
156
156
  this.status = status;
157
157
  }
@@ -172,38 +172,38 @@ var NotFoundError = class extends EasyCMSError {
172
172
  }
173
173
  };
174
174
  var QueryError = class extends EasyCMSError {
175
- constructor(message) {
176
- super(message, 400);
175
+ constructor(message2) {
176
+ super(message2, 400);
177
177
  this.name = "QueryError";
178
178
  }
179
179
  };
180
180
  var SchemaError = class extends EasyCMSError {
181
- constructor(message) {
182
- super(message, 500);
181
+ constructor(message2) {
182
+ super(message2, 500);
183
183
  this.name = "SchemaError";
184
184
  }
185
185
  };
186
186
  var UnauthorizedError = class extends EasyCMSError {
187
- constructor(message = "You must be logged in") {
188
- super(message, 401);
187
+ constructor(message2 = "You must be logged in") {
188
+ super(message2, 401);
189
189
  this.name = "UnauthorizedError";
190
190
  }
191
191
  };
192
192
  var ForbiddenError = class extends EasyCMSError {
193
- constructor(message = "You are not allowed to do this") {
194
- super(message, 403);
193
+ constructor(message2 = "You are not allowed to do this") {
194
+ super(message2, 403);
195
195
  this.name = "ForbiddenError";
196
196
  }
197
197
  };
198
198
  var TooManyRequestsError = class extends EasyCMSError {
199
- constructor(message) {
200
- super(message, 429);
199
+ constructor(message2) {
200
+ super(message2, 429);
201
201
  this.name = "TooManyRequestsError";
202
202
  }
203
203
  };
204
204
  var PayloadTooLargeError = class extends EasyCMSError {
205
- constructor(message) {
206
- super(message, 413);
205
+ constructor(message2) {
206
+ super(message2, 413);
207
207
  this.name = "PayloadTooLargeError";
208
208
  }
209
209
  };
@@ -293,22 +293,22 @@ async function stripFields(fields, doc, read2) {
293
293
  }
294
294
  return result;
295
295
  }
296
- async function filterInput(fields, data, update) {
296
+ async function filterInput(fields, data, update2) {
297
297
  const result = { ...data };
298
298
  for (const field of fields) {
299
299
  if (!(field.name in result)) continue;
300
- if (field.hidden || update && !await update.allows(field)) {
300
+ if (field.hidden || update2 && !await update2.allows(field)) {
301
301
  delete result[field.name];
302
302
  continue;
303
303
  }
304
304
  const value = result[field.name];
305
305
  if (field.type === "group" && value && typeof value === "object" && !Array.isArray(value)) {
306
- result[field.name] = await filterInput(field.fields, value, update);
306
+ result[field.name] = await filterInput(field.fields, value, update2);
307
307
  } else if (hasRows(field) && Array.isArray(value)) {
308
308
  result[field.name] = await Promise.all(
309
309
  value.map((row) => {
310
310
  const fields2 = rowFields(field, row);
311
- return fields2 && row && typeof row === "object" ? filterInput(fields2, row, update) : row;
311
+ return fields2 && row && typeof row === "object" ? filterInput(fields2, row, update2) : row;
312
312
  })
313
313
  );
314
314
  }
@@ -325,13 +325,15 @@ var VERSIONS = "document-versions";
325
325
  var SCHEDULED_JOBS = "scheduled-jobs";
326
326
  var WEBHOOK_DELIVERIES = "webhook-deliveries";
327
327
  var EMAIL_DELIVERIES = "email-deliveries";
328
+ var DATABASE_BACKUPS = "database-backups";
328
329
  var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
329
330
  SESSIONS,
330
331
  LOGIN_ATTEMPTS,
331
332
  VERSIONS,
332
333
  SCHEDULED_JOBS,
333
334
  WEBHOOK_DELIVERIES,
334
- EMAIL_DELIVERIES
335
+ EMAIL_DELIVERIES,
336
+ DATABASE_BACKUPS
335
337
  ]);
336
338
  var DEFAULT_ROLES = ["admin", "editor"];
337
339
  var adminOrSelf = ({ user }) => {
@@ -546,6 +548,25 @@ var webhookDeliveriesCollection = {
546
548
  { name: "error", type: "text" }
547
549
  ]
548
550
  };
551
+ var databaseBackupsCollection = {
552
+ slug: DATABASE_BACKUPS,
553
+ access: { read: nobody, create: nobody, update: nobody, delete: nobody },
554
+ fields: [
555
+ // pending | running | done | failed
556
+ { name: "state", type: "text", required: true, index: true },
557
+ // manual | scheduled
558
+ { name: "trigger", type: "text", required: true },
559
+ { name: "filename", type: "text" },
560
+ { name: "size", type: "number" },
561
+ { name: "startedAt", type: "text", index: true },
562
+ { name: "finishedAt", type: "text" },
563
+ { name: "error", type: "text" },
564
+ // Who started it (email), for manual ones.
565
+ { name: "author", type: "text" },
566
+ { name: "downloadedBy", type: "text" },
567
+ { name: "downloadedAt", type: "text" }
568
+ ]
569
+ };
549
570
  var emailDeliveriesCollection = {
550
571
  slug: EMAIL_DELIVERIES,
551
572
  access: { read: nobody, create: nobody, update: nobody, delete: nobody },
@@ -1089,6 +1110,386 @@ var Auth = class {
1089
1110
  }
1090
1111
  };
1091
1112
 
1113
+ // src/backups.ts
1114
+ import { mkdtemp, readFile as readFile2, rm as rm2 } from "fs/promises";
1115
+ import { tmpdir } from "os";
1116
+ import { join as join2 } from "path";
1117
+ import { promisify } from "util";
1118
+ import { gzip } from "zlib";
1119
+
1120
+ // src/copy.ts
1121
+ var BATCH = 200;
1122
+ async function copyDatabase(source, target, options = {}) {
1123
+ const from = source.transfer;
1124
+ const to = target.transfer;
1125
+ if (!from || !to) throw new EasyCMSError("This database adapter cannot copy databases", 400);
1126
+ if (from.schemaHash !== to.schemaHash) {
1127
+ throw new EasyCMSError(
1128
+ "The two configs describe different collections or fields. Copy between configs with the same collections (import one config into the other and change only `db`).",
1129
+ 400
1130
+ );
1131
+ }
1132
+ const tables = from.tables();
1133
+ for (const table of tables) {
1134
+ if (await to.count(table) > 0) {
1135
+ throw new EasyCMSError(
1136
+ `The target database is not empty ("${table}" has rows). Copy into a new, empty database.`,
1137
+ 400
1138
+ );
1139
+ }
1140
+ }
1141
+ let total = 0;
1142
+ for (const table of tables) {
1143
+ let copied = 0;
1144
+ for (; ; ) {
1145
+ const rows = await from.read(table, copied, BATCH);
1146
+ if (rows.length === 0) break;
1147
+ await to.write(table, rows);
1148
+ copied += rows.length;
1149
+ if (rows.length < BATCH) break;
1150
+ }
1151
+ total += copied;
1152
+ options.onTable?.({ table, rows: copied });
1153
+ }
1154
+ await to.finish();
1155
+ return { tables: tables.length, rows: total };
1156
+ }
1157
+
1158
+ // src/storage.ts
1159
+ import { mkdir, readFile, rm, stat, writeFile } from "fs/promises";
1160
+ import { dirname, isAbsolute, join, normalize, resolve, sep } from "path";
1161
+ function localStorage(options = {}) {
1162
+ let root = resolve(
1163
+ /* turbopackIgnore: true */
1164
+ options.dir ?? "uploads"
1165
+ );
1166
+ const pathOf = (key2) => {
1167
+ const path = normalize(join(
1168
+ /* turbopackIgnore: true */
1169
+ root,
1170
+ key2
1171
+ ));
1172
+ if (!path.startsWith(root + sep)) throw new Error(`Invalid storage key "${key2}"`);
1173
+ return path;
1174
+ };
1175
+ return {
1176
+ name: "local",
1177
+ init({ cwd }) {
1178
+ const dir = options.dir ?? "uploads";
1179
+ root = isAbsolute(dir) ? dir : resolve(
1180
+ /* turbopackIgnore: true */
1181
+ cwd,
1182
+ dir
1183
+ );
1184
+ },
1185
+ async put(key2, data) {
1186
+ const path = pathOf(key2);
1187
+ await mkdir(dirname(path), { recursive: true });
1188
+ await writeFile(path, data, { flag: "wx" });
1189
+ },
1190
+ async get(key2) {
1191
+ const path = pathOf(key2);
1192
+ const info = await stat(path).catch(() => void 0);
1193
+ if (!info?.isFile()) return null;
1194
+ return { body: new Uint8Array(await readFile(path)), size: info.size };
1195
+ },
1196
+ async delete(key2) {
1197
+ await rm(pathOf(key2), { force: true });
1198
+ }
1199
+ };
1200
+ }
1201
+
1202
+ // src/backups.ts
1203
+ var gzipAsync = promisify(gzip);
1204
+ var DEFAULT_BACKUP_TIME = "03:00";
1205
+ var DEFAULT_BACKUP_KEEP = 7;
1206
+ var DEFAULT_DIR = "backups";
1207
+ var STALE = 30 * 6e4;
1208
+ var PERIOD = { day: 864e5, week: 7 * 864e5 };
1209
+ var storages = /* @__PURE__ */ new WeakMap();
1210
+ function backupStorage(cms) {
1211
+ let storage = storages.get(cms);
1212
+ if (!storage) {
1213
+ storage = (async () => {
1214
+ const config = cms.config.backups;
1215
+ const adapter = config?.storage ?? localStorage({ dir: config?.dir ?? DEFAULT_DIR });
1216
+ await adapter.init?.({ cwd: cms.cwd });
1217
+ return adapter;
1218
+ })();
1219
+ storages.set(cms, storage);
1220
+ }
1221
+ return storage;
1222
+ }
1223
+ function backupUnavailable(cms) {
1224
+ if (cms.db.backup || cms.config.backups?.sqlite) return null;
1225
+ return `The ${cms.config.db.name} database needs the SQLite adapter to write backups: backups: { sqlite } with sqlite from @easy-cms/db-sqlite`;
1226
+ }
1227
+ async function writeBackupFile(cms, file) {
1228
+ if (cms.db.backup) return cms.db.backup(file);
1229
+ const factory = cms.config.backups?.sqlite;
1230
+ if (!factory) throw new EasyCMSError(backupUnavailable(cms), 400);
1231
+ const prefix = cms.db.tablePrefix;
1232
+ const scratch = `${file}.copy`;
1233
+ const adapter = factory({ url: `file:${scratch}`, ...prefix ? { tablePrefix: prefix } : {} });
1234
+ const target = await adapter.init({
1235
+ config: { ...cms.config, db: adapter },
1236
+ cwd: cms.cwd,
1237
+ schema: "push",
1238
+ logger: { ...cms.logger, info() {
1239
+ } }
1240
+ });
1241
+ try {
1242
+ await copyDatabase(cms.db, target);
1243
+ if (!target.backup) throw new EasyCMSError("backups.sqlite must be the SQLite adapter", 400);
1244
+ await target.backup(file);
1245
+ } finally {
1246
+ await target.destroy();
1247
+ for (const suffix of ["", "-wal", "-shm"])
1248
+ await rm2(`${scratch}${suffix}`, { force: true }).catch(() => {
1249
+ });
1250
+ }
1251
+ }
1252
+ var slug = (text) => text.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "easy-cms";
1253
+ function filenameFor(cms, date) {
1254
+ const pad = (n) => String(n).padStart(2, "0");
1255
+ const stamp = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}`;
1256
+ return `${slug(cms.config.admin.brand.name || "easy-cms")}-${stamp}.db.gz`;
1257
+ }
1258
+ function toAdmin(row) {
1259
+ const text = (value) => typeof value === "string" && value !== "" ? value : null;
1260
+ return {
1261
+ id: row.id,
1262
+ state: ["pending", "running", "done", "failed"].includes(row.state) ? row.state : "failed",
1263
+ trigger: row.trigger === "scheduled" ? "scheduled" : "manual",
1264
+ filename: text(row.filename),
1265
+ size: typeof row.size === "number" ? row.size : null,
1266
+ startedAt: text(row.startedAt),
1267
+ finishedAt: text(row.finishedAt),
1268
+ error: text(row.error),
1269
+ author: text(row.author),
1270
+ downloadedBy: text(row.downloadedBy),
1271
+ downloadedAt: text(row.downloadedAt)
1272
+ };
1273
+ }
1274
+ async function update(cms, row, data) {
1275
+ const { id, ...rest } = row;
1276
+ const next = { ...rest, ...data, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
1277
+ await cms.db.update({ collection: DATABASE_BACKUPS, id, data: next });
1278
+ return { ...row, ...data };
1279
+ }
1280
+ async function listBackups(cms) {
1281
+ const config = cms.config.backups;
1282
+ const storage = await backupStorage(cms);
1283
+ const { docs } = await cms.db.find({
1284
+ collection: DATABASE_BACKUPS,
1285
+ sort: ["-createdAt"],
1286
+ limit: 0,
1287
+ page: 1
1288
+ });
1289
+ return {
1290
+ settings: {
1291
+ every: config?.every ?? null,
1292
+ at: config?.at ?? DEFAULT_BACKUP_TIME,
1293
+ keep: config?.keep ?? DEFAULT_BACKUP_KEEP,
1294
+ storage: storage.name,
1295
+ dir: config?.storage ? null : config?.dir ?? DEFAULT_DIR,
1296
+ database: cms.config.db.name,
1297
+ unavailable: backupUnavailable(cms)
1298
+ },
1299
+ backups: docs.map(toAdmin)
1300
+ };
1301
+ }
1302
+ async function inProgress(cms, now) {
1303
+ const { docs } = await cms.db.find({
1304
+ collection: DATABASE_BACKUPS,
1305
+ where: { state: { in: ["pending", "running"] } },
1306
+ sort: ["-createdAt"],
1307
+ limit: 10,
1308
+ page: 1
1309
+ });
1310
+ return docs.find((row) => now - Date.parse(String(row.createdAt)) < STALE);
1311
+ }
1312
+ async function startBackup(cms, trigger, author) {
1313
+ const unavailable = backupUnavailable(cms);
1314
+ if (unavailable) throw new EasyCMSError(unavailable, 400);
1315
+ const now = /* @__PURE__ */ new Date();
1316
+ if (await inProgress(cms, now.getTime()))
1317
+ throw new EasyCMSError("A backup is already running", 409);
1318
+ const stamp = now.toISOString();
1319
+ const row = await cms.db.create({
1320
+ collection: DATABASE_BACKUPS,
1321
+ data: {
1322
+ state: "pending",
1323
+ trigger,
1324
+ filename: null,
1325
+ size: null,
1326
+ startedAt: null,
1327
+ finishedAt: null,
1328
+ error: null,
1329
+ author: author ?? null,
1330
+ downloadedBy: null,
1331
+ downloadedAt: null,
1332
+ createdAt: stamp,
1333
+ updatedAt: stamp
1334
+ }
1335
+ });
1336
+ void runBackup(cms, row).catch(
1337
+ (error) => cms.logger.error(`Backup failed: ${error.message}`)
1338
+ );
1339
+ return toAdmin(row);
1340
+ }
1341
+ async function runBackup(cms, record) {
1342
+ const started = /* @__PURE__ */ new Date();
1343
+ let row = await update(cms, record, { state: "running", startedAt: started.toISOString() });
1344
+ const dir = await mkdtemp(join2(tmpdir(), "easy-cms-backup-"));
1345
+ try {
1346
+ const file = join2(dir, "backup.db");
1347
+ await writeBackupFile(cms, file);
1348
+ const data = new Uint8Array(await gzipAsync(await readFile2(file)));
1349
+ const storage = await backupStorage(cms);
1350
+ const filename = filenameFor(cms, started);
1351
+ const key2 = await storage.get(filename) ? filename.replace(/\.db\.gz$/, `-${row.id}.db.gz`) : filename;
1352
+ await storage.put(key2, data, { contentType: "application/gzip" });
1353
+ row = await update(cms, row, {
1354
+ state: "done",
1355
+ filename: key2,
1356
+ size: data.byteLength,
1357
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString()
1358
+ });
1359
+ cms.logger.info(`Backup ${key2} written (${data.byteLength} bytes)`);
1360
+ await pruneBackups(cms);
1361
+ } catch (error) {
1362
+ await update(cms, row, {
1363
+ state: "failed",
1364
+ error: (error.message || String(error)).slice(0, 500),
1365
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString()
1366
+ });
1367
+ throw error;
1368
+ } finally {
1369
+ await rm2(dir, { recursive: true, force: true });
1370
+ }
1371
+ }
1372
+ async function pruneBackups(cms) {
1373
+ const keep = cms.config.backups?.keep ?? DEFAULT_BACKUP_KEEP;
1374
+ const storage = await backupStorage(cms);
1375
+ const { docs } = await cms.db.find({
1376
+ collection: DATABASE_BACKUPS,
1377
+ where: { state: { equals: "done" } },
1378
+ sort: ["-createdAt"],
1379
+ limit: 0,
1380
+ page: 1
1381
+ });
1382
+ for (const row of docs.slice(keep)) {
1383
+ if (typeof row.filename === "string") await storage.delete(row.filename).catch(() => {
1384
+ });
1385
+ await cms.db.delete({ collection: DATABASE_BACKUPS, id: row.id });
1386
+ }
1387
+ const old = new Date(Date.now() - 30 * 864e5).toISOString();
1388
+ const failed = await cms.db.find({
1389
+ collection: DATABASE_BACKUPS,
1390
+ where: { and: [{ state: { equals: "failed" } }, { createdAt: { lt: old } }] },
1391
+ sort: ["createdAt"],
1392
+ limit: 100,
1393
+ page: 1
1394
+ });
1395
+ for (const row of failed.docs) await cms.db.delete({ collection: DATABASE_BACKUPS, id: row.id });
1396
+ }
1397
+ function lastSlot(now, at) {
1398
+ const [hours2, minutes] = at.split(":").map(Number);
1399
+ const slot = new Date(now);
1400
+ slot.setHours(hours2, minutes, 0, 0);
1401
+ if (slot.getTime() > now.getTime()) slot.setDate(slot.getDate() - 1);
1402
+ return slot;
1403
+ }
1404
+ async function runDueBackups(cms, now = /* @__PURE__ */ new Date()) {
1405
+ if (backupUnavailable(cms)) return;
1406
+ const { docs } = await cms.db.find({
1407
+ collection: DATABASE_BACKUPS,
1408
+ where: { state: { in: ["pending", "running"] } },
1409
+ sort: ["createdAt"],
1410
+ limit: 10,
1411
+ page: 1
1412
+ });
1413
+ for (const row of docs) {
1414
+ const age = now.getTime() - Date.parse(String(row.updatedAt ?? row.createdAt));
1415
+ if (age < STALE) continue;
1416
+ if (row.state === "pending") await runBackup(cms, row).catch(() => {
1417
+ });
1418
+ else
1419
+ await update(cms, row, {
1420
+ state: "failed",
1421
+ error: "Interrupted: the server stopped during the backup",
1422
+ finishedAt: now.toISOString()
1423
+ });
1424
+ }
1425
+ const every = cms.config.backups?.every;
1426
+ if (!every) return;
1427
+ const slot = lastSlot(now, cms.config.backups?.at ?? DEFAULT_BACKUP_TIME);
1428
+ const latest = await cms.db.find({
1429
+ collection: DATABASE_BACKUPS,
1430
+ where: { trigger: { equals: "scheduled" } },
1431
+ sort: ["-createdAt"],
1432
+ limit: 1,
1433
+ page: 1
1434
+ });
1435
+ const last = latest.docs[0] ? Date.parse(String(latest.docs[0].createdAt)) : 0;
1436
+ const due = every === "day" ? last < slot.getTime() : last < slot.getTime() - PERIOD.week + PERIOD.day;
1437
+ if (!due) return;
1438
+ if (await inProgress(cms, now.getTime())) return;
1439
+ await startBackup(cms, "scheduled").catch(
1440
+ (error) => cms.logger.error(`Scheduled backup could not start: ${error.message}`)
1441
+ );
1442
+ }
1443
+ async function backupAttention(cms, now = Date.now()) {
1444
+ const every = cms.config.backups?.every;
1445
+ if (!every) return null;
1446
+ const [scheduled, done] = await Promise.all([
1447
+ cms.db.find({
1448
+ collection: DATABASE_BACKUPS,
1449
+ where: { trigger: { equals: "scheduled" } },
1450
+ sort: ["-createdAt"],
1451
+ limit: 1,
1452
+ page: 1
1453
+ }),
1454
+ cms.db.find({
1455
+ collection: DATABASE_BACKUPS,
1456
+ where: { state: { equals: "done" } },
1457
+ sort: ["-createdAt"],
1458
+ limit: 1,
1459
+ page: 1
1460
+ })
1461
+ ]);
1462
+ const failed = scheduled.docs[0]?.state === "failed";
1463
+ const lastDone = done.docs[0] ? String(done.docs[0].createdAt) : null;
1464
+ let since = lastDone ? Date.parse(lastDone) : void 0;
1465
+ if (since === void 0) {
1466
+ const first = await cms.db.find({ collection: USERS, sort: ["createdAt"], limit: 1, page: 1 });
1467
+ since = first.docs[0] ? Date.parse(String(first.docs[0].createdAt)) : now;
1468
+ }
1469
+ const overdue = now - since > 2 * PERIOD[every];
1470
+ return failed || overdue ? { id: "backups", failed, lastDone } : null;
1471
+ }
1472
+ async function downloadBackup(cms, id, by) {
1473
+ const row = await cms.db.findById({ collection: DATABASE_BACKUPS, id });
1474
+ if (row?.state !== "done" || typeof row.filename !== "string")
1475
+ throw new NotFoundError(DATABASE_BACKUPS, id);
1476
+ const file = await (await backupStorage(cms)).get(row.filename);
1477
+ if (!file)
1478
+ throw new EasyCMSError(`The file ${row.filename} is no longer in the backup storage`, 404);
1479
+ await update(cms, row, { downloadedBy: by, downloadedAt: (/* @__PURE__ */ new Date()).toISOString() });
1480
+ return { filename: row.filename, body: file.body };
1481
+ }
1482
+ async function deleteBackup(cms, id) {
1483
+ const row = await cms.db.findById({ collection: DATABASE_BACKUPS, id });
1484
+ if (!row) throw new NotFoundError(DATABASE_BACKUPS, id);
1485
+ if (row.state === "running" || row.state === "pending")
1486
+ throw new EasyCMSError("A backup in progress cannot be deleted", 409);
1487
+ if (typeof row.filename === "string")
1488
+ await (await backupStorage(cms)).delete(row.filename).catch(() => {
1489
+ });
1490
+ await cms.db.delete({ collection: DATABASE_BACKUPS, id });
1491
+ }
1492
+
1092
1493
  // src/config.ts
1093
1494
  var ADMIN_ICONS = [
1094
1495
  "file-text",
@@ -1175,44 +1576,6 @@ function configSignature(config) {
1175
1576
  }
1176
1577
  var signatures = /* @__PURE__ */ new WeakMap();
1177
1578
 
1178
- // src/copy.ts
1179
- var BATCH = 200;
1180
- async function copyDatabase(source, target, options = {}) {
1181
- const from = source.transfer;
1182
- const to = target.transfer;
1183
- if (!from || !to) throw new EasyCMSError("This database adapter cannot copy databases", 400);
1184
- if (from.schemaHash !== to.schemaHash) {
1185
- throw new EasyCMSError(
1186
- "The two configs describe different collections or fields. Copy between configs with the same collections (import one config into the other and change only `db`).",
1187
- 400
1188
- );
1189
- }
1190
- const tables = from.tables();
1191
- for (const table of tables) {
1192
- if (await to.count(table) > 0) {
1193
- throw new EasyCMSError(
1194
- `The target database is not empty ("${table}" has rows). Copy into a new, empty database.`,
1195
- 400
1196
- );
1197
- }
1198
- }
1199
- let total = 0;
1200
- for (const table of tables) {
1201
- let copied = 0;
1202
- for (; ; ) {
1203
- const rows = await from.read(table, copied, BATCH);
1204
- if (rows.length === 0) break;
1205
- await to.write(table, rows);
1206
- copied += rows.length;
1207
- if (rows.length < BATCH) break;
1208
- }
1209
- total += copied;
1210
- options.onTable?.({ table, rows: copied });
1211
- }
1212
- await to.finish();
1213
- return { tables: tables.length, rows: total };
1214
- }
1215
-
1216
1579
  // src/document.ts
1217
1580
  import { randomUUID } from "crypto";
1218
1581
  var EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -1331,7 +1694,7 @@ async function validateFields(fields, input, options, prefix = "") {
1331
1694
  for (const field of fields) {
1332
1695
  const path = prefix + field.name;
1333
1696
  const raw = input[field.name];
1334
- const fail = (message) => errors.push({ field: path, message });
1697
+ const fail = (message2) => errors.push({ field: path, message: message2 });
1335
1698
  if (field.type === "group") {
1336
1699
  if (!isEmpty(raw) && !isPlainObject(raw)) {
1337
1700
  fail("must be an object");
@@ -1360,7 +1723,7 @@ async function validateFields(fields, input, options, prefix = "") {
1360
1723
  for (const locale of localization.locales) {
1361
1724
  const own = locale === written;
1362
1725
  const localePath = own ? path : `${path}.${locale}`;
1363
- const failLocale = (message) => errors.push({ field: localePath, message });
1726
+ const failLocale = (message2) => errors.push({ field: localePath, message: message2 });
1364
1727
  const value2 = map[locale];
1365
1728
  if (isEmpty(value2) || Array.isArray(value2) && value2.length === 0) {
1366
1729
  if (own && field.required && !options.skipRequired) failLocale("is required");
@@ -1543,17 +1906,19 @@ function consoleEmail(options = {}) {
1543
1906
  const sent = [];
1544
1907
  const adapter = {
1545
1908
  name: "console",
1909
+ // `log`: the admin says it prints to the server log instead of sending.
1910
+ describe: () => [{ key: "delivery", value: "log" }],
1546
1911
  from: options.from ?? "Easy CMS <no-reply@localhost>",
1547
1912
  sent,
1548
- async send(message) {
1549
- sent.push(message);
1913
+ async send(message2) {
1914
+ sent.push(message2);
1550
1915
  const list = (value) => value === void 0 ? void 0 : typeof value === "string" ? value : value.join(", ");
1551
1916
  const lines = [
1552
- `Email to ${list(message.to)}${message.cc ? `, cc ${list(message.cc)}` : ""}`,
1553
- `From: ${message.from}${message.replyTo ? ` (reply to ${message.replyTo})` : ""}`,
1554
- `Subject: ${message.subject}`,
1917
+ `Email to ${list(message2.to)}${message2.cc ? `, cc ${list(message2.cc)}` : ""}`,
1918
+ `From: ${message2.from}${message2.replyTo ? ` (reply to ${message2.replyTo})` : ""}`,
1919
+ `Subject: ${message2.subject}`,
1555
1920
  "",
1556
- message.text ?? message.html ?? ""
1921
+ message2.text ?? message2.html ?? ""
1557
1922
  ];
1558
1923
  (options.log ?? console.log)(lines.join("\n"));
1559
1924
  }
@@ -1581,14 +1946,14 @@ var Mailer = class {
1581
1946
  * for the sending too. Without an `email` adapter in the config, the email is skipped with a
1582
1947
  * warning.
1583
1948
  */
1584
- async send(message) {
1949
+ async send(message2) {
1585
1950
  if (!this.adapter) {
1586
1951
  this.logger.warn(
1587
- `Email "${message.subject}" not sent: set \`email\` in the config (e.g. consoleEmail() in development)`
1952
+ `Email "${message2.subject}" not sent: set \`email\` in the config (e.g. consoleEmail() in development)`
1588
1953
  );
1589
1954
  return;
1590
1955
  }
1591
- const body = JSON.stringify(message);
1956
+ const body = JSON.stringify(message2);
1592
1957
  let id;
1593
1958
  try {
1594
1959
  id = await this.queue?.add({
@@ -1601,7 +1966,7 @@ var Mailer = class {
1601
1966
  } catch (error) {
1602
1967
  this.logger.error(`Could not save email: ${error.message}`);
1603
1968
  }
1604
- this.track(this.deliver(message, body, id));
1969
+ this.track(this.deliver(message2, body, id));
1605
1970
  }
1606
1971
  /** Waits for emails being sent, e.g. before a serverless function returns. */
1607
1972
  async flush() {
@@ -1637,25 +2002,25 @@ var Mailer = class {
1637
2002
  return { sent, failed };
1638
2003
  }
1639
2004
  /** One attempt now for a saved email (the admin's Retry): `undefined` when sent, else the error. */
1640
- async sendNow(message) {
2005
+ async sendNow(message2) {
1641
2006
  if (!this.adapter) return "email is not set up (`email` in the config)";
1642
- return this.attempt(JSON.parse(message));
2007
+ return this.attempt(JSON.parse(message2));
1643
2008
  }
1644
2009
  track(delivery) {
1645
2010
  const tracked = delivery.catch((error) => this.logger.error(`Email queue failed: ${error.message}`)).finally(() => this.pending.delete(tracked));
1646
2011
  this.pending.add(tracked);
1647
2012
  }
1648
- async deliver(message, body, id) {
1649
- const error = await this.attempt(message);
2013
+ async deliver(message2, body, id) {
2014
+ const error = await this.attempt(message2);
1650
2015
  if (!error) {
1651
2016
  if (id !== void 0) await this.queue?.remove(id);
1652
2017
  return;
1653
2018
  }
1654
2019
  if (id === void 0 || !this.queue) {
1655
- this.logger.error(`Email "${message.subject}" failed: ${error}`);
2020
+ this.logger.error(`Email "${message2.subject}" failed: ${error}`);
1656
2021
  return;
1657
2022
  }
1658
- this.logger.warn(`Email "${message.subject}" failed, retrying later: ${error}`);
2023
+ this.logger.warn(`Email "${message2.subject}" failed, retrying later: ${error}`);
1659
2024
  await this.queue.update(id, {
1660
2025
  message: body,
1661
2026
  attempts: 1,
@@ -1665,12 +2030,12 @@ var Mailer = class {
1665
2030
  });
1666
2031
  }
1667
2032
  /** `undefined` when sent, else the error. */
1668
- async attempt(message) {
2033
+ async attempt(message2) {
1669
2034
  const adapter = this.adapter;
1670
- const from = message.from ?? adapter.from;
2035
+ const from = message2.from ?? adapter.from;
1671
2036
  if (!from) return "no sender: set `from` on the message or the email adapter";
1672
2037
  try {
1673
- await adapter.send({ ...message, from });
2038
+ await adapter.send({ ...message2, from });
1674
2039
  return void 0;
1675
2040
  } catch (error) {
1676
2041
  return error.message || String(error);
@@ -1800,7 +2165,7 @@ function applyFieldTypes(config) {
1800
2165
 
1801
2166
  // src/load-config.ts
1802
2167
  import { existsSync } from "fs";
1803
- import { resolve } from "path";
2168
+ import { resolve as resolve2 } from "path";
1804
2169
  import { createJiti } from "jiti";
1805
2170
 
1806
2171
  // src/webhooks.ts
@@ -1992,6 +2357,8 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set([
1992
2357
  "document-versions",
1993
2358
  "scheduled-jobs",
1994
2359
  "webhook-deliveries",
2360
+ "email-deliveries",
2361
+ "database-backups",
1995
2362
  "api-keys",
1996
2363
  "jobs",
1997
2364
  "migrations",
@@ -2002,7 +2369,7 @@ var SLUG_PATTERN = /^[a-z][a-z0-9_-]*$/;
2002
2369
  var FIELD_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
2003
2370
  function validateConfig(config) {
2004
2371
  const issues = [];
2005
- const add = (path, message, hint) => issues.push(hint ? { path, message, hint } : { path, message });
2372
+ const add = (path, message2, hint) => issues.push(hint ? { path, message: message2, hint } : { path, message: message2 });
2006
2373
  if (typeof config !== "object" || config === null) {
2007
2374
  add("config", "must be an object", "export default defineConfig({ ... })");
2008
2375
  return issues;
@@ -2014,6 +2381,7 @@ function validateConfig(config) {
2014
2381
  validateAdmin(config, add);
2015
2382
  validateAdminViews(config, add);
2016
2383
  validateUpload(config, add);
2384
+ validateBackups(config, add);
2017
2385
  validateAuth(config, add);
2018
2386
  validateCors(config.cors, add);
2019
2387
  validateLocalization(config, add);
@@ -2023,17 +2391,17 @@ function validateConfig(config) {
2023
2391
  const collectionSlugs = /* @__PURE__ */ new Set([...BUILTIN_COLLECTIONS]);
2024
2392
  const seenCollections = /* @__PURE__ */ new Set();
2025
2393
  collections.forEach((collection2, i) => {
2026
- const slug = checkSlug(collection2, `collections[${i}]`, seenCollections, add);
2027
- if (slug) collectionSlugs.add(slug);
2394
+ const slug2 = checkSlug(collection2, `collections[${i}]`, seenCollections, add);
2395
+ if (slug2) collectionSlugs.add(slug2);
2028
2396
  });
2029
2397
  const menu = config.admin?.menu;
2030
2398
  if (menu !== void 0) {
2031
2399
  if (!Array.isArray(menu)) {
2032
2400
  add("admin.menu", "must be an array of collection slugs", "e.g. menu: ['posts', 'media']");
2033
2401
  } else {
2034
- for (const [i, slug] of menu.entries()) {
2035
- if (typeof slug !== "string" || !collectionSlugs.has(slug))
2036
- add(`admin.menu[${i}]`, `unknown collection ${JSON.stringify(slug)}`);
2402
+ for (const [i, slug2] of menu.entries()) {
2403
+ if (typeof slug2 !== "string" || !collectionSlugs.has(slug2))
2404
+ add(`admin.menu[${i}]`, `unknown collection ${JSON.stringify(slug2)}`);
2037
2405
  }
2038
2406
  }
2039
2407
  }
@@ -2374,10 +2742,10 @@ function validateWebhooks(config, add) {
2374
2742
  if (!WEBHOOK_EVENTS.includes(event))
2375
2743
  add(`${path}.events`, `unknown event "${event}"`, `use: ${WEBHOOK_EVENTS.join(", ")}`);
2376
2744
  }
2377
- for (const slug of hook?.collections ?? [])
2378
- if (!collections.has(slug)) add(`${path}.collections`, `unknown collection "${slug}"`);
2379
- for (const slug of hook?.globals ?? [])
2380
- if (!globals.has(slug)) add(`${path}.globals`, `unknown global "${slug}"`);
2745
+ for (const slug2 of hook?.collections ?? [])
2746
+ if (!collections.has(slug2)) add(`${path}.collections`, `unknown collection "${slug2}"`);
2747
+ for (const slug2 of hook?.globals ?? [])
2748
+ if (!globals.has(slug2)) add(`${path}.globals`, `unknown global "${slug2}"`);
2381
2749
  }
2382
2750
  }
2383
2751
  function validateCors(cors, add) {
@@ -2430,6 +2798,32 @@ function validateUpload(config, add) {
2430
2798
  add("upload.fromURL.allowPrivate", "must be true or false");
2431
2799
  }
2432
2800
  var HOST_PATTERN = /^(\*|(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*)$/i;
2801
+ function validateBackups(config, add) {
2802
+ const backups = config.backups;
2803
+ if (backups === void 0) return;
2804
+ if (typeof backups !== "object" || backups === null) {
2805
+ add("backups", "must be an object", "e.g. backups: { every: 'day', keep: 7 }");
2806
+ return;
2807
+ }
2808
+ const { every, at, keep, dir, storage, sqlite } = backups;
2809
+ if (every !== void 0 && every !== "day" && every !== "week")
2810
+ add("backups.every", 'must be "day" or "week"');
2811
+ if (at !== void 0 && (typeof at !== "string" || !/^([01]\d|2[0-3]):[0-5]\d$/.test(at)))
2812
+ add("backups.at", 'must be a time like "03:00"');
2813
+ if (keep !== void 0 && !(Number.isInteger(keep) && keep >= 1))
2814
+ add("backups.keep", "must be a whole number, 1 or more");
2815
+ if (dir !== void 0 && (typeof dir !== "string" || dir.trim() === ""))
2816
+ add("backups.dir", "must be a folder name");
2817
+ if (typeof dir === "string" && dir.replace(/^\.\//, "").replace(/\/+$/, "") === (config.upload?.dir ?? "uploads").replace(/^\.\//, "").replace(/\/+$/, ""))
2818
+ add("backups.dir", "must not be the uploads folder: uploads are served publicly");
2819
+ if (storage !== void 0 && (typeof storage !== "object" || storage === null || typeof storage.put !== "function"))
2820
+ add("backups.storage", "must be a storage adapter, e.g. s3Storage()");
2821
+ if (sqlite !== void 0 && typeof sqlite !== "function")
2822
+ add(
2823
+ "backups.sqlite",
2824
+ "must be the sqlite adapter, e.g. import { sqlite } from '@easy-cms/db-sqlite'"
2825
+ );
2826
+ }
2433
2827
  function asArray(value, path, add) {
2434
2828
  if (value === void 0) return [];
2435
2829
  if (!Array.isArray(value)) {
@@ -2439,24 +2833,24 @@ function asArray(value, path, add) {
2439
2833
  return value;
2440
2834
  }
2441
2835
  function checkSlug(container, path, seen, add) {
2442
- const slug = container.slug;
2443
- if (typeof slug !== "string" || !SLUG_PATTERN.test(slug)) {
2836
+ const slug2 = container.slug;
2837
+ if (typeof slug2 !== "string" || !SLUG_PATTERN.test(slug2)) {
2444
2838
  add(
2445
2839
  `${path}.slug`,
2446
- `must be lowercase letters, digits, "-" or "_", starting with a letter (got ${JSON.stringify(slug)})`
2840
+ `must be lowercase letters, digits, "-" or "_", starting with a letter (got ${JSON.stringify(slug2)})`
2447
2841
  );
2448
2842
  return void 0;
2449
2843
  }
2450
- if (RESERVED_SLUGS.has(slug)) {
2451
- add(`${path}.slug`, `"${slug}" is reserved by Easy CMS`, "choose another slug");
2844
+ if (RESERVED_SLUGS.has(slug2)) {
2845
+ add(`${path}.slug`, `"${slug2}" is reserved by Easy CMS`, "choose another slug");
2452
2846
  return void 0;
2453
2847
  }
2454
- if (seen.has(slug)) {
2455
- add(`${path}.slug`, `duplicate slug "${slug}"`);
2848
+ if (seen.has(slug2)) {
2849
+ add(`${path}.slug`, `duplicate slug "${slug2}"`);
2456
2850
  return void 0;
2457
2851
  }
2458
- seen.add(slug);
2459
- return slug;
2852
+ seen.add(slug2);
2853
+ return slug2;
2460
2854
  }
2461
2855
  function validateContainer(container, path, collectionSlugs, add) {
2462
2856
  if (!Array.isArray(container.fields)) {
@@ -2901,7 +3295,8 @@ async function resolveConfig(input) {
2901
3295
  ...usesSchedule(config) ? [scheduledJobsCollection] : [],
2902
3296
  ...(config.webhooks?.length ?? 0) > 0 ? [webhookDeliveriesCollection] : [],
2903
3297
  ...config.apiKeys ? [apiKeysCollection] : [],
2904
- ...config.email ? [emailDeliveriesCollection] : []
3298
+ ...config.email ? [emailDeliveriesCollection] : [],
3299
+ databaseBackupsCollection
2905
3300
  ],
2906
3301
  globals: config.globals ?? [],
2907
3302
  endpoints: config.endpoints ?? [],
@@ -2930,7 +3325,7 @@ var CONFIG_FILE_NAMES = [
2930
3325
  ];
2931
3326
  function findConfigFile(cwd = process.cwd()) {
2932
3327
  for (const name of CONFIG_FILE_NAMES) {
2933
- const path = resolve(
3328
+ const path = resolve2(
2934
3329
  /* turbopackIgnore: true */
2935
3330
  cwd,
2936
3331
  name
@@ -2949,7 +3344,7 @@ async function importConfig(file) {
2949
3344
  }
2950
3345
  async function loadConfig(options = {}) {
2951
3346
  const cwd = options.cwd ?? process.cwd();
2952
- const file = options.configFile ? resolve(cwd, options.configFile) : findConfigFile(cwd);
3347
+ const file = options.configFile ? resolve2(cwd, options.configFile) : findConfigFile(cwd);
2953
3348
  if (!file || !existsSync(file)) {
2954
3349
  const looked = options.configFile ?? CONFIG_FILE_NAMES.join(", ");
2955
3350
  throw new Error(
@@ -3067,9 +3462,9 @@ function localizeSort(fields, sort, locale, locales = []) {
3067
3462
 
3068
3463
  // src/logger.ts
3069
3464
  var consoleLogger = {
3070
- info: (message) => console.info(`[easy-cms] ${message}`),
3071
- warn: (message) => console.warn(`[easy-cms] ${message}`),
3072
- error: (message) => console.error(`[easy-cms] ${message}`)
3465
+ info: (message2) => console.info(`[easy-cms] ${message2}`),
3466
+ warn: (message2) => console.warn(`[easy-cms] ${message2}`),
3467
+ error: (message2) => console.error(`[easy-cms] ${message2}`)
3073
3468
  };
3074
3469
  var silentLogger = { info() {
3075
3470
  }, warn() {
@@ -3188,12 +3583,12 @@ async function populate(load, collections, fields, docs, depth) {
3188
3583
  }
3189
3584
  const found = /* @__PURE__ */ new Map();
3190
3585
  await Promise.all(
3191
- [...wanted].map(async ([slug, ids]) => {
3192
- const collection2 = bySlug.get(slug);
3586
+ [...wanted].map(async ([slug2, ids]) => {
3587
+ const collection2 = bySlug.get(slug2);
3193
3588
  if (!collection2) return;
3194
3589
  const raw = await load(collection2, [...ids]);
3195
3590
  const populated = await populate(load, collections, collection2.fields, raw, depth - 1);
3196
- found.set(slug, new Map(populated.map((d) => [d.id, d])));
3591
+ found.set(slug2, new Map(populated.map((d) => [d.id, d])));
3197
3592
  })
3198
3593
  );
3199
3594
  for (const slot of slots) slot.assign(found.get(slot.collection) ?? /* @__PURE__ */ new Map());
@@ -3248,8 +3643,8 @@ import { BlockList, isIP } from "net";
3248
3643
  var RemoteFileError = class extends Error {
3249
3644
  /** `413` when the file is too large, otherwise `400`. */
3250
3645
  status;
3251
- constructor(message, status = 400) {
3252
- super(message);
3646
+ constructor(message2, status = 400) {
3647
+ super(message2);
3253
3648
  this.name = "RemoteFileError";
3254
3649
  this.status = status;
3255
3650
  }
@@ -3446,50 +3841,6 @@ function read(response, maxBytes, deadline) {
3446
3841
  });
3447
3842
  }
3448
3843
 
3449
- // src/storage.ts
3450
- import { mkdir, readFile, rm, stat, writeFile } from "fs/promises";
3451
- import { dirname, isAbsolute, join, normalize, resolve as resolve2, sep } from "path";
3452
- function localStorage(options = {}) {
3453
- let root = resolve2(
3454
- /* turbopackIgnore: true */
3455
- options.dir ?? "uploads"
3456
- );
3457
- const pathOf = (key2) => {
3458
- const path = normalize(join(
3459
- /* turbopackIgnore: true */
3460
- root,
3461
- key2
3462
- ));
3463
- if (!path.startsWith(root + sep)) throw new Error(`Invalid storage key "${key2}"`);
3464
- return path;
3465
- };
3466
- return {
3467
- name: "local",
3468
- init({ cwd }) {
3469
- const dir = options.dir ?? "uploads";
3470
- root = isAbsolute(dir) ? dir : resolve2(
3471
- /* turbopackIgnore: true */
3472
- cwd,
3473
- dir
3474
- );
3475
- },
3476
- async put(key2, data) {
3477
- const path = pathOf(key2);
3478
- await mkdir(dirname(path), { recursive: true });
3479
- await writeFile(path, data, { flag: "wx" });
3480
- },
3481
- async get(key2) {
3482
- const path = pathOf(key2);
3483
- const info = await stat(path).catch(() => void 0);
3484
- if (!info?.isFile()) return null;
3485
- return { body: new Uint8Array(await readFile(path)), size: info.size };
3486
- },
3487
- async delete(key2) {
3488
- await rm(pathOf(key2), { force: true });
3489
- }
3490
- };
3491
- }
3492
-
3493
3844
  // src/versions.ts
3494
3845
  var DEFAULT_MAX_VERSIONS = 50;
3495
3846
  function versionLimit(config) {
@@ -3497,8 +3848,8 @@ function versionLimit(config) {
3497
3848
  if (!versions) return void 0;
3498
3849
  return versions === true ? DEFAULT_MAX_VERSIONS : versions.max ?? DEFAULT_MAX_VERSIONS;
3499
3850
  }
3500
- var collectionParent = (slug) => slug;
3501
- var globalParent = (slug) => `global:${slug}`;
3851
+ var collectionParent = (slug2) => slug2;
3852
+ var globalParent = (slug2) => `global:${slug2}`;
3502
3853
  function snapshotOf(fields, doc) {
3503
3854
  const hidden = new Set(fields.filter((f) => f.hidden).map((f) => f.name));
3504
3855
  const snapshot = {};
@@ -4020,17 +4371,17 @@ var EasyCMS = class {
4020
4371
  return token ? verifyPreviewToken(this.config.secret, token) : null;
4021
4372
  }
4022
4373
  /** Live preview of a global. See `preview`. */
4023
- async previewGlobal(slug, data, options = {}) {
4024
- const config = this.global(slug);
4374
+ async previewGlobal(slug2, data, options = {}) {
4375
+ const config = this.global(slug2);
4025
4376
  const guard = guardOf(options);
4026
4377
  await this.checkGlobalAccess(config, "update", guard);
4027
- const raw = asObject(data, slug);
4378
+ const raw = asObject(data, slug2);
4028
4379
  const input = await filterInput(
4029
4380
  config.fields,
4030
4381
  raw,
4031
4382
  this.fieldChecker("update", guard, void 0, raw)
4032
4383
  );
4033
- const saved = await this.db.findGlobal({ slug }) ?? {};
4384
+ const saved = await this.db.findGlobal({ slug: slug2 }) ?? {};
4034
4385
  const current = await this.globalDraft(config, saved);
4035
4386
  const merged = generateSlugs(
4036
4387
  config.fields,
@@ -4200,11 +4551,11 @@ var EasyCMS = class {
4200
4551
  const [out] = await this.output(config, [existing], guard, { depth: 0, draft: true });
4201
4552
  return out;
4202
4553
  }
4203
- async findGlobal(slug, options = {}) {
4204
- const config = this.global(slug);
4554
+ async findGlobal(slug2, options = {}) {
4555
+ const config = this.global(slug2);
4205
4556
  const guard = guardOf(options);
4206
4557
  await this.checkGlobalAccess(config, "read", guard);
4207
- const saved = await this.db.findGlobal({ slug });
4558
+ const saved = await this.db.findGlobal({ slug: slug2 });
4208
4559
  const stored = saved && options.draft ? await this.globalDraft(config, saved) : saved;
4209
4560
  const data = fillMissing(
4210
4561
  config.fields,
@@ -4218,33 +4569,33 @@ var EasyCMS = class {
4218
4569
  const { id: _id, ...doc } = out;
4219
4570
  return doc;
4220
4571
  }
4221
- async updateGlobal(slug, data, options = {}) {
4222
- return this.saveGlobal(slug, asObject(data, slug), options);
4572
+ async updateGlobal(slug2, data, options = {}) {
4573
+ return this.saveGlobal(slug2, asObject(data, slug2), options);
4223
4574
  }
4224
4575
  /** Takes a global off the site (`status: 'draft'`). See `unpublish`. */
4225
- async unpublishGlobal(slug, options = {}) {
4226
- if (!this.global(slug).drafts) throw new QueryError(`"${slug}" has no drafts`);
4227
- return this.saveGlobal(slug, { status: "draft" }, options, "unpublish");
4576
+ async unpublishGlobal(slug2, options = {}) {
4577
+ if (!this.global(slug2).drafts) throw new QueryError(`"${slug2}" has no drafts`);
4578
+ return this.saveGlobal(slug2, { status: "draft" }, options, "unpublish");
4228
4579
  }
4229
4580
  /** Throws away the unpublished draft of a published global. */
4230
- async discardGlobalDraft(slug, options = {}) {
4231
- const config = this.global(slug);
4581
+ async discardGlobalDraft(slug2, options = {}) {
4582
+ const config = this.global(slug2);
4232
4583
  const guard = guardOf(options);
4233
4584
  await this.checkGlobalAccess(config, "update", guard);
4234
- const saved = await this.db.findGlobal({ slug });
4585
+ const saved = await this.db.findGlobal({ slug: slug2 });
4235
4586
  if (saved && await this.globalDraft(config, saved) !== saved) {
4236
- await this.saveVersion(config, globalParent(slug), 0, saved, guard);
4587
+ await this.saveVersion(config, globalParent(slug2), 0, saved, guard);
4237
4588
  }
4238
- return this.findGlobal(slug, { ...options, draft: true });
4589
+ return this.findGlobal(slug2, { ...options, draft: true });
4239
4590
  }
4240
4591
  /** Saved versions of a global, newest first. Needs update access. */
4241
- async findGlobalVersions(slug, options = {}) {
4242
- await this.globalVersionTarget(slug, options);
4243
- return this.versions.list(globalParent(slug), 0, options.limit ?? 20, options.page ?? 1);
4592
+ async findGlobalVersions(slug2, options = {}) {
4593
+ await this.globalVersionTarget(slug2, options);
4594
+ return this.versions.list(globalParent(slug2), 0, options.limit ?? 20, options.page ?? 1);
4244
4595
  }
4245
- async findGlobalVersion(slug, versionId, options = {}) {
4246
- const config = await this.globalVersionTarget(slug, options);
4247
- const version = await this.versions.get(globalParent(slug), 0, versionId);
4596
+ async findGlobalVersion(slug2, versionId, options = {}) {
4597
+ const config = await this.globalVersionTarget(slug2, options);
4598
+ const version = await this.versions.get(globalParent(slug2), 0, versionId);
4248
4599
  if (!version) return null;
4249
4600
  const data = fillMissing(
4250
4601
  config.fields,
@@ -4258,21 +4609,21 @@ var EasyCMS = class {
4258
4609
  return { ...version, data: { ...doc, updatedAt: version.createdAt } };
4259
4610
  }
4260
4611
  /** Makes an old version of a global current (as a draft when the global has drafts). */
4261
- async restoreGlobalVersion(slug, versionId, options = {}) {
4262
- const config = await this.globalVersionTarget(slug, options);
4263
- const version = await this.versions.get(globalParent(slug), 0, versionId);
4264
- if (!version) throw new NotFoundError(`${slug} version`, versionId);
4612
+ async restoreGlobalVersion(slug2, versionId, options = {}) {
4613
+ const config = await this.globalVersionTarget(slug2, options);
4614
+ const version = await this.versions.get(globalParent(slug2), 0, versionId);
4615
+ if (!version) throw new NotFoundError(`${slug2} version`, versionId);
4265
4616
  const { status: _status, ...data } = version.data;
4266
4617
  return this.saveGlobal(
4267
- slug,
4618
+ slug2,
4268
4619
  config.drafts ? { ...data, status: "draft" } : data,
4269
4620
  options,
4270
4621
  "restore"
4271
4622
  );
4272
4623
  }
4273
- async globalVersionTarget(slug, options) {
4274
- const config = this.global(slug);
4275
- if (!versionLimit(config)) throw new QueryError(`"${slug}" has no versions`);
4624
+ async globalVersionTarget(slug2, options) {
4625
+ const config = this.global(slug2);
4626
+ if (!versionLimit(config)) throw new QueryError(`"${slug2}" has no versions`);
4276
4627
  await this.checkGlobalAccess(config, "update", guardOf(options));
4277
4628
  return config;
4278
4629
  }
@@ -4283,18 +4634,18 @@ var EasyCMS = class {
4283
4634
  if (version?.status !== "draft") return saved;
4284
4635
  return { ...version.data, updatedAt: version.createdAt, status: "draft" };
4285
4636
  }
4286
- async saveGlobal(slug, raw, options, mode = "save") {
4287
- const config = this.global(slug);
4637
+ async saveGlobal(slug2, raw, options, mode = "save") {
4638
+ const config = this.global(slug2);
4288
4639
  const guard = guardOf(options);
4289
4640
  await this.checkGlobalAccess(config, "update", guard);
4290
4641
  if (config.drafts && (mode === "unpublish" || raw.status === "published"))
4291
- this.checkKey(guard, { global: slug }, "publish");
4642
+ this.checkKey(guard, { global: slug2 }, "publish");
4292
4643
  const input = await filterInput(
4293
4644
  config.fields,
4294
4645
  raw,
4295
4646
  this.fieldChecker("update", guard, void 0, raw)
4296
4647
  );
4297
- const existing = await this.db.findGlobal({ slug }) ?? {};
4648
+ const existing = await this.db.findGlobal({ slug: slug2 }) ?? {};
4298
4649
  const current = await this.globalDraft(config, existing);
4299
4650
  const localized = mode === "restore" ? input : this.toMaps(config, input, current, options);
4300
4651
  const merged = generateSlugs(
@@ -4318,26 +4669,26 @@ var EasyCMS = class {
4318
4669
  const now = (/* @__PURE__ */ new Date()).toISOString();
4319
4670
  if (mode !== "unpublish" && this.separateDrafts(config) && existing.status === "published" && prepared.status === "draft") {
4320
4671
  const draft = { ...prepared, updatedAt: now };
4321
- await this.saveVersion(config, globalParent(slug), 0, draft, guard);
4322
- await this.notify(config.hooks?.afterChange, "afterChange", slug, {
4672
+ await this.saveVersion(config, globalParent(slug2), 0, draft, guard);
4673
+ await this.notify(config.hooks?.afterChange, "afterChange", slug2, {
4323
4674
  ...base,
4324
4675
  doc: draft,
4325
4676
  previousDoc: current,
4326
4677
  operation: "update"
4327
4678
  });
4328
4679
  this.emit(config, "draft", draft);
4329
- return this.findGlobal(slug, { ...options, draft: true });
4680
+ return this.findGlobal(slug2, { ...options, draft: true });
4330
4681
  }
4331
- const doc = await this.db.updateGlobal({ slug, data: { ...prepared, updatedAt: now } });
4332
- await this.saveVersion(config, globalParent(slug), 0, doc, guard);
4333
- await this.notify(config.hooks?.afterChange, "afterChange", slug, {
4682
+ const doc = await this.db.updateGlobal({ slug: slug2, data: { ...prepared, updatedAt: now } });
4683
+ await this.saveVersion(config, globalParent(slug2), 0, doc, guard);
4684
+ await this.notify(config.hooks?.afterChange, "afterChange", slug2, {
4334
4685
  ...base,
4335
4686
  doc,
4336
4687
  previousDoc: current,
4337
4688
  operation: "update"
4338
4689
  });
4339
4690
  this.emit(config, "update", doc, existing.status);
4340
- return this.findGlobal(slug, { ...options, draft: true });
4691
+ return this.findGlobal(slug2, { ...options, draft: true });
4341
4692
  }
4342
4693
  /**
4343
4694
  * What `user` may do with one document. Resolves `where`-style access
@@ -4370,8 +4721,8 @@ var EasyCMS = class {
4370
4721
  * the email is queued; it is sent in the background and retried from the queue if sending
4371
4722
  * fails. Without `email` in the config, the email is skipped with a warning.
4372
4723
  */
4373
- sendEmail(message) {
4374
- return this.mailer.send(message);
4724
+ sendEmail(message2) {
4725
+ return this.mailer.send(message2);
4375
4726
  }
4376
4727
  /** Waits for emails being sent, e.g. before a serverless function returns. */
4377
4728
  flushEmails() {
@@ -4440,6 +4791,9 @@ var EasyCMS = class {
4440
4791
  async runJobs(now = /* @__PURE__ */ new Date()) {
4441
4792
  const scheduled = await this.runScheduled(now);
4442
4793
  await this.pruneFailedDeliveries(now);
4794
+ await runDueBackups(this, now).catch(
4795
+ (error) => this.logger.error(`Backups: ${error.message}`)
4796
+ );
4443
4797
  return {
4444
4798
  ...scheduled,
4445
4799
  webhooks: await this.retryWebhooks(now),
@@ -4509,9 +4863,9 @@ var EasyCMS = class {
4509
4863
  const action = row.action;
4510
4864
  try {
4511
4865
  if (parent.startsWith("global:")) {
4512
- const slug = parent.slice("global:".length);
4513
- if (action === "publish") await this.saveGlobal(slug, { status: "published" }, {});
4514
- else await this.saveGlobal(slug, { status: "draft" }, {}, "unpublish");
4866
+ const slug2 = parent.slice("global:".length);
4867
+ if (action === "publish") await this.saveGlobal(slug2, { status: "published" }, {});
4868
+ else await this.saveGlobal(slug2, { status: "draft" }, {}, "unpublish");
4515
4869
  } else if (action === "publish") {
4516
4870
  await this.updateDocument(parent, row.doc, { status: "published" }, { depth: 0 });
4517
4871
  } else {
@@ -4530,12 +4884,12 @@ var EasyCMS = class {
4530
4884
  });
4531
4885
  } catch (error) {
4532
4886
  failed++;
4533
- const message = error.message;
4534
- this.logger.error(`Scheduled ${action} of ${parent} ${row.doc} failed: ${message}`);
4887
+ const message2 = error.message;
4888
+ this.logger.error(`Scheduled ${action} of ${parent} ${row.doc} failed: ${message2}`);
4535
4889
  await this.db.update({
4536
4890
  collection: SCHEDULED_JOBS,
4537
4891
  id: row.id,
4538
- data: { ...row, state: "failed", error: message.slice(0, 500) }
4892
+ data: { ...row, state: "failed", error: message2.slice(0, 500) }
4539
4893
  });
4540
4894
  }
4541
4895
  }
@@ -4555,17 +4909,17 @@ var EasyCMS = class {
4555
4909
  const { parsed } = await this.scheduleTarget(collection2, id, options);
4556
4910
  await this.removeJob(collection2, parsed, jobId);
4557
4911
  }
4558
- async scheduleGlobal(slug, job, options = {}) {
4559
- await this.globalScheduleTarget(slug, options);
4560
- return this.addJob(`global:${slug}`, 0, job, options);
4912
+ async scheduleGlobal(slug2, job, options = {}) {
4913
+ await this.globalScheduleTarget(slug2, options);
4914
+ return this.addJob(`global:${slug2}`, 0, job, options);
4561
4915
  }
4562
- async scheduledGlobal(slug, options = {}) {
4563
- await this.globalScheduleTarget(slug, options);
4564
- return this.pendingJobs(`global:${slug}`, 0);
4916
+ async scheduledGlobal(slug2, options = {}) {
4917
+ await this.globalScheduleTarget(slug2, options);
4918
+ return this.pendingJobs(`global:${slug2}`, 0);
4565
4919
  }
4566
- async cancelGlobalSchedule(slug, jobId, options = {}) {
4567
- await this.globalScheduleTarget(slug, options);
4568
- await this.removeJob(`global:${slug}`, 0, jobId);
4920
+ async cancelGlobalSchedule(slug2, jobId, options = {}) {
4921
+ await this.globalScheduleTarget(slug2, options);
4922
+ await this.removeJob(`global:${slug2}`, 0, jobId);
4569
4923
  }
4570
4924
  async scheduleTarget(collection2, id, options) {
4571
4925
  const config = this.collection(collection2);
@@ -4577,11 +4931,11 @@ var EasyCMS = class {
4577
4931
  this.checkKey(guardOf(options), { collection: collection2 }, "publish");
4578
4932
  return { config, parsed };
4579
4933
  }
4580
- async globalScheduleTarget(slug, options) {
4581
- const config = this.global(slug);
4582
- if (!config.schedule) throw new QueryError(`"${slug}" has no schedule`);
4934
+ async globalScheduleTarget(slug2, options) {
4935
+ const config = this.global(slug2);
4936
+ if (!config.schedule) throw new QueryError(`"${slug2}" has no schedule`);
4583
4937
  await this.checkGlobalAccess(config, "update", guardOf(options));
4584
- this.checkKey(guardOf(options), { global: slug }, "publish");
4938
+ this.checkKey(guardOf(options), { global: slug2 }, "publish");
4585
4939
  }
4586
4940
  async addJob(parent, doc, job, options) {
4587
4941
  const runAt = new Date(job.at);
@@ -4715,14 +5069,14 @@ var EasyCMS = class {
4715
5069
  }
4716
5070
  // -------------------------------------------------------------------------
4717
5071
  /** @internal */
4718
- collection(slug) {
4719
- const config = this.config.collections.find((c) => c.slug === slug);
4720
- if (!config) throw new QueryError(`Unknown collection "${slug}"`);
5072
+ collection(slug2) {
5073
+ const config = this.config.collections.find((c) => c.slug === slug2);
5074
+ if (!config) throw new QueryError(`Unknown collection "${slug2}"`);
4721
5075
  return config;
4722
5076
  }
4723
- global(slug) {
4724
- const config = this.config.globals.find((g) => g.slug === slug);
4725
- if (!config) throw new QueryError(`Unknown global "${slug}"`);
5077
+ global(slug2) {
5078
+ const config = this.config.globals.find((g) => g.slug === slug2);
5079
+ if (!config) throw new QueryError(`Unknown global "${slug2}"`);
4726
5080
  return config;
4727
5081
  }
4728
5082
  /** Refuses what a request's API key does not allow (see `ApiKeyPermissions`). */
@@ -4972,13 +5326,13 @@ var EasyCMS = class {
4972
5326
  return current;
4973
5327
  }
4974
5328
  /** Runs hooks after the change is saved: failures are logged, never undo the save (NFR-REL-03). */
4975
- async notify(hooks, name, slug, args) {
5329
+ async notify(hooks, name, slug2, args) {
4976
5330
  for (const hook of hooks ?? []) {
4977
5331
  try {
4978
5332
  await hook(args);
4979
5333
  } catch (error) {
4980
5334
  this.logger.error(
4981
- `${name} hook of "${slug}" failed: ${error instanceof Error ? error.message : String(error)}`
5335
+ `${name} hook of "${slug2}" failed: ${error instanceof Error ? error.message : String(error)}`
4982
5336
  );
4983
5337
  }
4984
5338
  }
@@ -5080,9 +5434,9 @@ var EasyCMS = class {
5080
5434
  const value = data[field.name];
5081
5435
  if (field.localized && value && typeof value === "object") {
5082
5436
  const map = { ...value };
5083
- for (const [locale, slug] of Object.entries(map)) {
5084
- if (typeof slug === "string" && slug !== "")
5085
- map[locale] = await unique(`${field.name}.${locale}`, slug);
5437
+ for (const [locale, slug2] of Object.entries(map)) {
5438
+ if (typeof slug2 === "string" && slug2 !== "")
5439
+ map[locale] = await unique(`${field.name}.${locale}`, slug2);
5086
5440
  }
5087
5441
  data[field.name] = map;
5088
5442
  } else if (typeof value === "string" && value !== "") {
@@ -5221,11 +5575,11 @@ function asObject(data, name) {
5221
5575
 
5222
5576
  // src/rest/admin-modules.ts
5223
5577
  import { createHash as createHash3 } from "crypto";
5224
- import { readFile as readFile2 } from "fs/promises";
5578
+ import { readFile as readFile3 } from "fs/promises";
5225
5579
  import { createRequire } from "module";
5226
- import { join as join2 } from "path";
5580
+ import { join as join3 } from "path";
5227
5581
  function resolveAdminModule(specifier, cwd = process.cwd()) {
5228
- const bases = [join2(cwd, "package.json")];
5582
+ const bases = [join3(cwd, "package.json")];
5229
5583
  const entry = process.argv[1];
5230
5584
  if (entry && !specifier.startsWith(".")) bases.push(entry);
5231
5585
  for (const base of bases) {
@@ -5259,7 +5613,7 @@ function adminModuleUrls(cms) {
5259
5613
  async function readAdminModule(cms, index) {
5260
5614
  const file = files(cms)[index];
5261
5615
  if (!file) return void 0;
5262
- const body = await readFile2(file, "utf8").catch(() => void 0);
5616
+ const body = await readFile3(file, "utf8").catch(() => void 0);
5263
5617
  if (body === void 0) return void 0;
5264
5618
  return { body, etag: `"${createHash3("sha1").update(body).digest("base64url")}"` };
5265
5619
  }
@@ -5277,7 +5631,7 @@ function parse(text) {
5277
5631
  return {};
5278
5632
  }
5279
5633
  }
5280
- function toAdmin(kind, row) {
5634
+ function toAdmin2(kind, row) {
5281
5635
  const base = {
5282
5636
  id: row.id,
5283
5637
  state: row.state === "failed" ? "failed" : "pending",
@@ -5300,13 +5654,13 @@ function toAdmin(kind, row) {
5300
5654
  body: String(row.body ?? "")
5301
5655
  };
5302
5656
  }
5303
- const message = parse(row.message);
5304
- const to = message.to;
5657
+ const message2 = parse(row.message);
5658
+ const to = message2.to;
5305
5659
  return {
5306
5660
  ...base,
5307
5661
  kind,
5308
5662
  to: Array.isArray(to) ? to.map(String) : typeof to === "string" ? [to] : [],
5309
- subject: String(message.subject ?? "")
5663
+ subject: String(message2.subject ?? "")
5310
5664
  };
5311
5665
  }
5312
5666
  async function listDeliveries(cms, kind, state, page) {
@@ -5325,7 +5679,7 @@ async function listDeliveries(cms, kind, state, page) {
5325
5679
  cms.db.count({ collection: collection2, where: { state: { equals: "pending" } } })
5326
5680
  ]);
5327
5681
  return {
5328
- docs: found.docs.map((row) => toAdmin(kind, row)),
5682
+ docs: found.docs.map((row) => toAdmin2(kind, row)),
5329
5683
  totalDocs: found.totalDocs,
5330
5684
  page: found.page,
5331
5685
  totalPages: found.totalPages,
@@ -5381,6 +5735,82 @@ async function deleteFailedDeliveries(cms, kind) {
5381
5735
  return { deleted };
5382
5736
  }
5383
5737
 
5738
+ // src/rest/admin-email.ts
5739
+ var EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
5740
+ var LIMIT = 5;
5741
+ var WINDOW = 10 * 6e4;
5742
+ var sentAt = /* @__PURE__ */ new WeakMap();
5743
+ var message = (error) => error instanceof Error ? error.message || error.name : String(error);
5744
+ function adminEmail(cms) {
5745
+ const adapter = cms.config.email;
5746
+ if (!adapter) return { configured: false, name: null, from: null, settings: [], canVerify: false };
5747
+ let settings = [];
5748
+ try {
5749
+ settings = [...adapter.describe?.() ?? []].map((s) => ({
5750
+ key: String(s.key),
5751
+ value: s.value === null || s.value === void 0 ? null : String(s.value),
5752
+ ...s.source ? { source: String(s.source) } : {}
5753
+ }));
5754
+ } catch {
5755
+ }
5756
+ return {
5757
+ configured: true,
5758
+ name: adapter.name ?? "custom",
5759
+ from: adapter.from ?? null,
5760
+ settings,
5761
+ canVerify: typeof adapter.verify === "function"
5762
+ };
5763
+ }
5764
+ async function verifyEmail(cms) {
5765
+ const adapter = cms.config.email;
5766
+ if (!adapter?.verify)
5767
+ return { ok: false, error: "This email adapter cannot check its connection" };
5768
+ try {
5769
+ await adapter.verify();
5770
+ return { ok: true };
5771
+ } catch (error) {
5772
+ return { ok: false, error: message(error) };
5773
+ }
5774
+ }
5775
+ async function sendTestEmail(cms, user, to, locale) {
5776
+ const adapter = cms.config.email;
5777
+ if (!adapter) return { ok: false, error: "Email is not set up (`email` in the config)" };
5778
+ const address = typeof to === "string" && to.trim() ? to.trim() : user.email;
5779
+ if (!EMAIL.test(address))
5780
+ throw new ValidationError("email", [{ field: "to", message: "must be an email address" }]);
5781
+ const times = sentAt.get(cms) ?? /* @__PURE__ */ new Map();
5782
+ sentAt.set(cms, times);
5783
+ const key2 = String(user.id);
5784
+ const now = Date.now();
5785
+ const recent = (times.get(key2) ?? []).filter((t) => now - t < WINDOW);
5786
+ if (recent.length >= LIMIT)
5787
+ throw new TooManyRequestsError("Too many test emails: try again in a few minutes");
5788
+ times.set(key2, [...recent, now]);
5789
+ const from = adapter.from;
5790
+ if (!from) return { ok: false, error: "No sender: set `from` on the email adapter" };
5791
+ const site = cms.config.admin.brand.name || "Easy CMS";
5792
+ const th = locale === "th" || locale !== "en" && cms.config.admin.locale === "th";
5793
+ const time = (/* @__PURE__ */ new Date()).toISOString();
5794
+ const subject = th ? `\u0E2D\u0E35\u0E40\u0E21\u0E25\u0E17\u0E14\u0E2A\u0E2D\u0E1A\u0E08\u0E32\u0E01 ${site}` : `Test email from ${site}`;
5795
+ const text = th ? `\u0E2D\u0E35\u0E40\u0E21\u0E25\u0E19\u0E35\u0E49\u0E2A\u0E48\u0E07\u0E08\u0E32\u0E01\u0E2B\u0E19\u0E49\u0E32 \u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32 \u2192 \u0E2D\u0E35\u0E40\u0E21\u0E25 \u0E02\u0E2D\u0E07 ${site} \u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E17\u0E14\u0E2A\u0E2D\u0E1A\u0E01\u0E32\u0E23\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32
5796
+
5797
+ \u0E2A\u0E48\u0E07\u0E42\u0E14\u0E22: ${user.email}
5798
+ Adapter: ${adapter.name ?? "custom"}
5799
+ \u0E1C\u0E39\u0E49\u0E2A\u0E48\u0E07: ${from}
5800
+ \u0E40\u0E27\u0E25\u0E32: ${time}` : `This email was sent from Settings \u2192 Email in ${site} to test the settings.
5801
+
5802
+ Sent by: ${user.email}
5803
+ Adapter: ${adapter.name ?? "custom"}
5804
+ From: ${from}
5805
+ Time: ${time}`;
5806
+ try {
5807
+ await adapter.send({ to: address, subject, text, from });
5808
+ return { ok: true };
5809
+ } catch (error) {
5810
+ return { ok: false, error: message(error) };
5811
+ }
5812
+ }
5813
+
5384
5814
  // src/rest/admin-schema.ts
5385
5815
  var SETTINGS = /* @__PURE__ */ new Set([USERS, API_KEYS]);
5386
5816
  function componentRef(component) {
@@ -5390,7 +5820,7 @@ function componentRef(component) {
5390
5820
  async function allowed(access, user) {
5391
5821
  return await evaluateAccess(access, { user }) !== false;
5392
5822
  }
5393
- async function serializeFields(fields, update, localized) {
5823
+ async function serializeFields(fields, update2, localized) {
5394
5824
  const out = [];
5395
5825
  for (const field of fields) {
5396
5826
  if (field.hidden) continue;
@@ -5399,7 +5829,7 @@ async function serializeFields(fields, update, localized) {
5399
5829
  if (field.required) f.required = true;
5400
5830
  if (field.unique) f.unique = true;
5401
5831
  if (field.defaultValue !== void 0) f.defaultValue = field.defaultValue;
5402
- if (!await update.allows(field)) f.readOnly = true;
5832
+ if (!await update2.allows(field)) f.readOnly = true;
5403
5833
  if (field.localized && localized) f.localized = true;
5404
5834
  if (field.position === "sidebar") f.position = "sidebar";
5405
5835
  if (field.admin?.component || field.admin?.after?.length || field.admin?.cell) {
@@ -5446,7 +5876,7 @@ async function serializeFields(fields, update, localized) {
5446
5876
  case "array":
5447
5877
  if (field.minRows !== void 0) f.minRows = field.minRows;
5448
5878
  if (field.maxRows !== void 0) f.maxRows = field.maxRows;
5449
- f.fields = await serializeFields(field.fields, update, localized);
5879
+ f.fields = await serializeFields(field.fields, update2, localized);
5450
5880
  break;
5451
5881
  case "blocks":
5452
5882
  if (field.minRows !== void 0) f.minRows = field.minRows;
@@ -5455,12 +5885,12 @@ async function serializeFields(fields, update, localized) {
5455
5885
  field.blocks.map(async (block) => ({
5456
5886
  slug: block.slug,
5457
5887
  ...block.labels ? { labels: block.labels } : {},
5458
- fields: await serializeFields(block.fields, update, localized)
5888
+ fields: await serializeFields(block.fields, update2, localized)
5459
5889
  }))
5460
5890
  );
5461
5891
  break;
5462
5892
  case "group":
5463
- f.fields = await serializeFields(field.fields, update, localized);
5893
+ f.fields = await serializeFields(field.fields, update2, localized);
5464
5894
  break;
5465
5895
  }
5466
5896
  out.push(f);
@@ -5574,11 +6004,11 @@ async function widgets(cms, user) {
5574
6004
  }
5575
6005
 
5576
6006
  // src/version.ts
5577
- var VERSION = true ? "0.30.0" : "0.0.0-dev";
6007
+ var VERSION = true ? "0.32.0" : "0.0.0-dev";
5578
6008
 
5579
6009
  // src/rest/admin-status.ts
5580
6010
  var DAY = 864e5;
5581
- var has = (cms, slug) => cms.config.collections.some((c) => c.slug === slug);
6011
+ var has = (cms, slug2) => cms.config.collections.some((c) => c.slug === slug2);
5582
6012
  async function adminStatus(cms) {
5583
6013
  const config = cms.config;
5584
6014
  const attention = [];
@@ -5631,6 +6061,8 @@ async function adminStatus(cms) {
5631
6061
  });
5632
6062
  if (count > 0) attention.push({ id: "scheduled", count });
5633
6063
  }
6064
+ const backups = await backupAttention(cms);
6065
+ if (backups) attention.push(backups);
5634
6066
  if (!config.email) attention.push({ id: "no-email" });
5635
6067
  if (!config.serverURL && process.env.NODE_ENV === "production")
5636
6068
  attention.push({ id: "no-server-url" });
@@ -5665,8 +6097,8 @@ async function pickerFilter(cms, url, target, user) {
5665
6097
  const spec = forCollection ?? forGlobal;
5666
6098
  if (spec === null) return void 0;
5667
6099
  const dot = spec.indexOf(".");
5668
- const slug = dot > 0 ? spec.slice(0, dot) : "";
5669
- const owner = forCollection !== null ? cms.config.collections.find((c) => c.slug === slug) : cms.config.globals.find((g) => g.slug === slug);
6100
+ const slug2 = dot > 0 ? spec.slice(0, dot) : "";
6101
+ const owner = forCollection !== null ? cms.config.collections.find((c) => c.slug === slug2) : cms.config.globals.find((g) => g.slug === slug2);
5670
6102
  const field = owner ? fieldAt(owner.fields, spec.slice(dot + 1)) : void 0;
5671
6103
  if (field?.type !== "relationship" || field.to !== target)
5672
6104
  throw new QueryError(`filterFor: no relationship to "${target}" at "${spec}"`);
@@ -6014,6 +6446,55 @@ async function route(cms, ctx, method, segments, options) {
6014
6446
  }
6015
6447
  return { body: await cms.runJobs() };
6016
6448
  }
6449
+ if (first === "admin" && second === "backups") {
6450
+ if (!ctx.user) throw new UnauthorizedError();
6451
+ if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
6452
+ const [, , id2, action, extra] = segments;
6453
+ if (extra !== void 0) throw new HttpError("Not found", 404);
6454
+ if (id2 === void 0) {
6455
+ if (method === "GET") return { body: await listBackups(cms) };
6456
+ if (method === "POST")
6457
+ return { status: 202, body: await startBackup(cms, "manual", ctx.user.email) };
6458
+ throw methodNotAllowed(ctx, "GET, POST");
6459
+ }
6460
+ const parsed = parseId(id2);
6461
+ if (parsed === void 0) throw new HttpError("Not found", 404);
6462
+ if (action === "download") {
6463
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
6464
+ const file = await downloadBackup(cms, parsed, ctx.user.email);
6465
+ return {
6466
+ body: new Response(file.body, {
6467
+ headers: {
6468
+ "content-type": "application/gzip",
6469
+ "content-disposition": `attachment; filename="${file.filename}"`,
6470
+ "cache-control": "no-store",
6471
+ "x-content-type-options": "nosniff"
6472
+ }
6473
+ })
6474
+ };
6475
+ }
6476
+ if (action !== void 0) throw new HttpError("Not found", 404);
6477
+ if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
6478
+ await deleteBackup(cms, parsed);
6479
+ return { body: { deleted: 1 } };
6480
+ }
6481
+ if (first === "admin" && second === "email") {
6482
+ if (!ctx.user) throw new UnauthorizedError();
6483
+ if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
6484
+ const [, , action, extra] = segments;
6485
+ if (extra !== void 0) throw new HttpError("Not found", 404);
6486
+ if (action === void 0) {
6487
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
6488
+ return { body: adminEmail(cms) };
6489
+ }
6490
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
6491
+ if (action === "verify") return { body: await verifyEmail(cms) };
6492
+ if (action === "test") {
6493
+ const body = await readJson(ctx.request);
6494
+ return { body: await sendTestEmail(cms, ctx.user, body.to, body.locale) };
6495
+ }
6496
+ throw new HttpError("Not found", 404);
6497
+ }
6017
6498
  if (first === "admin" && second === "deliveries") {
6018
6499
  if (!ctx.user) throw new UnauthorizedError();
6019
6500
  if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
@@ -6253,47 +6734,47 @@ async function documentAction(cms, ctx, method, collection2, id, path) {
6253
6734
  }
6254
6735
  throw new HttpError("Not found", 404);
6255
6736
  }
6256
- async function globalAction(cms, ctx, method, slug, path) {
6737
+ async function globalAction(cms, ctx, method, slug2, path) {
6257
6738
  const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
6258
6739
  const depth = parseDepth(ctx.url);
6259
6740
  const [action, versionId, extra] = path;
6260
6741
  if (action === "versions" && versionId === void 0) {
6261
6742
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
6262
- return { body: await cms.findGlobalVersions(slug, { ...access, ...parsePage(ctx.url) }) };
6743
+ return { body: await cms.findGlobalVersions(slug2, { ...access, ...parsePage(ctx.url) }) };
6263
6744
  }
6264
6745
  if (action === "versions" && versionId !== void 0 && extra === void 0) {
6265
6746
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
6266
- const version = await cms.findGlobalVersion(slug, versionId, { ...access, ...depth });
6267
- if (!version) throw new NotFoundError(`${slug} version`, versionId);
6747
+ const version = await cms.findGlobalVersion(slug2, versionId, { ...access, ...depth });
6748
+ if (!version) throw new NotFoundError(`${slug2} version`, versionId);
6268
6749
  return { body: version };
6269
6750
  }
6270
6751
  if (action === "versions" && versionId !== void 0 && extra === "restore" && path.length === 3) {
6271
6752
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
6272
- return { body: await cms.restoreGlobalVersion(slug, versionId, { ...access, ...depth }) };
6753
+ return { body: await cms.restoreGlobalVersion(slug2, versionId, { ...access, ...depth }) };
6273
6754
  }
6274
6755
  if (action === "schedule" && path.length <= 2) {
6275
6756
  if (path.length === 2) {
6276
6757
  if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
6277
- await cms.cancelGlobalSchedule(slug, versionId, access);
6758
+ await cms.cancelGlobalSchedule(slug2, versionId, access);
6278
6759
  return { body: { message: "Cancelled" } };
6279
6760
  }
6280
- if (method === "GET") return { body: await cms.scheduledGlobal(slug, access) };
6761
+ if (method === "GET") return { body: await cms.scheduledGlobal(slug2, access) };
6281
6762
  if (method !== "POST") throw methodNotAllowed(ctx, "GET, POST");
6282
6763
  const body = await readJson(ctx.request);
6283
- return { status: 201, body: await cms.scheduleGlobal(slug, scheduleJob(body), access) };
6764
+ return { status: 201, body: await cms.scheduleGlobal(slug2, scheduleJob(body), access) };
6284
6765
  }
6285
6766
  if (path.length === 1 && action === "preview") {
6286
6767
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
6287
6768
  const body = await readJson(ctx.request);
6288
- const result = await cms.previewGlobal(slug, body, { ...access, ...depth });
6289
- const token = cms.createPreviewToken({ global: slug });
6769
+ const result = await cms.previewGlobal(slug2, body, { ...access, ...depth });
6770
+ const token = cms.createPreviewToken({ global: slug2 });
6290
6771
  return { body: { ...result, url: result.url && withPreviewToken(result.url, token) } };
6291
6772
  }
6292
6773
  if (path.length === 1 && (action === "unpublish" || action === "discard-draft")) {
6293
6774
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
6294
6775
  const options = { ...access, ...depth };
6295
6776
  return {
6296
- body: action === "unpublish" ? await cms.unpublishGlobal(slug, options) : await cms.discardGlobalDraft(slug, options)
6777
+ body: action === "unpublish" ? await cms.unpublishGlobal(slug2, options) : await cms.discardGlobalDraft(slug2, options)
6297
6778
  };
6298
6779
  }
6299
6780
  throw new HttpError("Not found", 404);
@@ -6471,11 +6952,11 @@ function errorResponse(cms, error, headers, production) {
6471
6952
  if (error instanceof UnauthorizedError) headers.set("www-authenticate", "Bearer");
6472
6953
  return new Response(JSON.stringify({ errors }), { status: error.status, headers });
6473
6954
  }
6474
- const message = error instanceof Error ? error.message : String(error);
6955
+ const message2 = error instanceof Error ? error.message : String(error);
6475
6956
  cms.logger.error(
6476
- `REST request failed: ${error instanceof Error ? error.stack ?? message : message}`
6957
+ `REST request failed: ${error instanceof Error ? error.stack ?? message2 : message2}`
6477
6958
  );
6478
- const body = { errors: [{ message: production ? "Internal Server Error" : message }] };
6959
+ const body = { errors: [{ message: production ? "Internal Server Error" : message2 }] };
6479
6960
  return new Response(JSON.stringify(body), { status: 500, headers });
6480
6961
  }
6481
6962
 
@@ -6626,7 +7107,7 @@ function generateTypes(config) {
6626
7107
  for (const global2 of config.globals) {
6627
7108
  parts.push("", globalInterface(global2, n.globals.get(global2.slug), n));
6628
7109
  }
6629
- const map = (entries) => [...entries].map(([slug, name]) => ` ${key(slug)}: ${name}`).join("\n");
7110
+ const map = (entries) => [...entries].map(([slug2, name]) => ` ${key(slug2)}: ${name}`).join("\n");
6630
7111
  parts.push(
6631
7112
  "",
6632
7113
  "/** Document type by collection slug. */",
@@ -6725,5 +7206,6 @@ export {
6725
7206
  sniffMimeType,
6726
7207
  validateConfig,
6727
7208
  validateFields,
6728
- verifyPassword
7209
+ verifyPassword,
7210
+ writeBackupFile
6729
7211
  };