@easy-cms/core 0.31.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.
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
  };
@@ -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@]+$/;
@@ -1802,7 +2165,7 @@ function applyFieldTypes(config) {
1802
2165
 
1803
2166
  // src/load-config.ts
1804
2167
  import { existsSync } from "fs";
1805
- import { resolve } from "path";
2168
+ import { resolve as resolve2 } from "path";
1806
2169
  import { createJiti } from "jiti";
1807
2170
 
1808
2171
  // src/webhooks.ts
@@ -1994,6 +2357,8 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set([
1994
2357
  "document-versions",
1995
2358
  "scheduled-jobs",
1996
2359
  "webhook-deliveries",
2360
+ "email-deliveries",
2361
+ "database-backups",
1997
2362
  "api-keys",
1998
2363
  "jobs",
1999
2364
  "migrations",
@@ -2016,6 +2381,7 @@ function validateConfig(config) {
2016
2381
  validateAdmin(config, add);
2017
2382
  validateAdminViews(config, add);
2018
2383
  validateUpload(config, add);
2384
+ validateBackups(config, add);
2019
2385
  validateAuth(config, add);
2020
2386
  validateCors(config.cors, add);
2021
2387
  validateLocalization(config, add);
@@ -2025,17 +2391,17 @@ function validateConfig(config) {
2025
2391
  const collectionSlugs = /* @__PURE__ */ new Set([...BUILTIN_COLLECTIONS]);
2026
2392
  const seenCollections = /* @__PURE__ */ new Set();
2027
2393
  collections.forEach((collection2, i) => {
2028
- const slug = checkSlug(collection2, `collections[${i}]`, seenCollections, add);
2029
- if (slug) collectionSlugs.add(slug);
2394
+ const slug2 = checkSlug(collection2, `collections[${i}]`, seenCollections, add);
2395
+ if (slug2) collectionSlugs.add(slug2);
2030
2396
  });
2031
2397
  const menu = config.admin?.menu;
2032
2398
  if (menu !== void 0) {
2033
2399
  if (!Array.isArray(menu)) {
2034
2400
  add("admin.menu", "must be an array of collection slugs", "e.g. menu: ['posts', 'media']");
2035
2401
  } else {
2036
- for (const [i, slug] of menu.entries()) {
2037
- if (typeof slug !== "string" || !collectionSlugs.has(slug))
2038
- 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)}`);
2039
2405
  }
2040
2406
  }
2041
2407
  }
@@ -2376,10 +2742,10 @@ function validateWebhooks(config, add) {
2376
2742
  if (!WEBHOOK_EVENTS.includes(event))
2377
2743
  add(`${path}.events`, `unknown event "${event}"`, `use: ${WEBHOOK_EVENTS.join(", ")}`);
2378
2744
  }
2379
- for (const slug of hook?.collections ?? [])
2380
- if (!collections.has(slug)) add(`${path}.collections`, `unknown collection "${slug}"`);
2381
- for (const slug of hook?.globals ?? [])
2382
- 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}"`);
2383
2749
  }
2384
2750
  }
2385
2751
  function validateCors(cors, add) {
@@ -2432,6 +2798,32 @@ function validateUpload(config, add) {
2432
2798
  add("upload.fromURL.allowPrivate", "must be true or false");
2433
2799
  }
2434
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
+ }
2435
2827
  function asArray(value, path, add) {
2436
2828
  if (value === void 0) return [];
2437
2829
  if (!Array.isArray(value)) {
@@ -2441,24 +2833,24 @@ function asArray(value, path, add) {
2441
2833
  return value;
2442
2834
  }
2443
2835
  function checkSlug(container, path, seen, add) {
2444
- const slug = container.slug;
2445
- if (typeof slug !== "string" || !SLUG_PATTERN.test(slug)) {
2836
+ const slug2 = container.slug;
2837
+ if (typeof slug2 !== "string" || !SLUG_PATTERN.test(slug2)) {
2446
2838
  add(
2447
2839
  `${path}.slug`,
2448
- `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)})`
2449
2841
  );
2450
2842
  return void 0;
2451
2843
  }
2452
- if (RESERVED_SLUGS.has(slug)) {
2453
- 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");
2454
2846
  return void 0;
2455
2847
  }
2456
- if (seen.has(slug)) {
2457
- add(`${path}.slug`, `duplicate slug "${slug}"`);
2848
+ if (seen.has(slug2)) {
2849
+ add(`${path}.slug`, `duplicate slug "${slug2}"`);
2458
2850
  return void 0;
2459
2851
  }
2460
- seen.add(slug);
2461
- return slug;
2852
+ seen.add(slug2);
2853
+ return slug2;
2462
2854
  }
2463
2855
  function validateContainer(container, path, collectionSlugs, add) {
2464
2856
  if (!Array.isArray(container.fields)) {
@@ -2903,7 +3295,8 @@ async function resolveConfig(input) {
2903
3295
  ...usesSchedule(config) ? [scheduledJobsCollection] : [],
2904
3296
  ...(config.webhooks?.length ?? 0) > 0 ? [webhookDeliveriesCollection] : [],
2905
3297
  ...config.apiKeys ? [apiKeysCollection] : [],
2906
- ...config.email ? [emailDeliveriesCollection] : []
3298
+ ...config.email ? [emailDeliveriesCollection] : [],
3299
+ databaseBackupsCollection
2907
3300
  ],
2908
3301
  globals: config.globals ?? [],
2909
3302
  endpoints: config.endpoints ?? [],
@@ -2932,7 +3325,7 @@ var CONFIG_FILE_NAMES = [
2932
3325
  ];
2933
3326
  function findConfigFile(cwd = process.cwd()) {
2934
3327
  for (const name of CONFIG_FILE_NAMES) {
2935
- const path = resolve(
3328
+ const path = resolve2(
2936
3329
  /* turbopackIgnore: true */
2937
3330
  cwd,
2938
3331
  name
@@ -2951,7 +3344,7 @@ async function importConfig(file) {
2951
3344
  }
2952
3345
  async function loadConfig(options = {}) {
2953
3346
  const cwd = options.cwd ?? process.cwd();
2954
- const file = options.configFile ? resolve(cwd, options.configFile) : findConfigFile(cwd);
3347
+ const file = options.configFile ? resolve2(cwd, options.configFile) : findConfigFile(cwd);
2955
3348
  if (!file || !existsSync(file)) {
2956
3349
  const looked = options.configFile ?? CONFIG_FILE_NAMES.join(", ");
2957
3350
  throw new Error(
@@ -3190,12 +3583,12 @@ async function populate(load, collections, fields, docs, depth) {
3190
3583
  }
3191
3584
  const found = /* @__PURE__ */ new Map();
3192
3585
  await Promise.all(
3193
- [...wanted].map(async ([slug, ids]) => {
3194
- const collection2 = bySlug.get(slug);
3586
+ [...wanted].map(async ([slug2, ids]) => {
3587
+ const collection2 = bySlug.get(slug2);
3195
3588
  if (!collection2) return;
3196
3589
  const raw = await load(collection2, [...ids]);
3197
3590
  const populated = await populate(load, collections, collection2.fields, raw, depth - 1);
3198
- found.set(slug, new Map(populated.map((d) => [d.id, d])));
3591
+ found.set(slug2, new Map(populated.map((d) => [d.id, d])));
3199
3592
  })
3200
3593
  );
3201
3594
  for (const slot of slots) slot.assign(found.get(slot.collection) ?? /* @__PURE__ */ new Map());
@@ -3448,50 +3841,6 @@ function read(response, maxBytes, deadline) {
3448
3841
  });
3449
3842
  }
3450
3843
 
3451
- // src/storage.ts
3452
- import { mkdir, readFile, rm, stat, writeFile } from "fs/promises";
3453
- import { dirname, isAbsolute, join, normalize, resolve as resolve2, sep } from "path";
3454
- function localStorage(options = {}) {
3455
- let root = resolve2(
3456
- /* turbopackIgnore: true */
3457
- options.dir ?? "uploads"
3458
- );
3459
- const pathOf = (key2) => {
3460
- const path = normalize(join(
3461
- /* turbopackIgnore: true */
3462
- root,
3463
- key2
3464
- ));
3465
- if (!path.startsWith(root + sep)) throw new Error(`Invalid storage key "${key2}"`);
3466
- return path;
3467
- };
3468
- return {
3469
- name: "local",
3470
- init({ cwd }) {
3471
- const dir = options.dir ?? "uploads";
3472
- root = isAbsolute(dir) ? dir : resolve2(
3473
- /* turbopackIgnore: true */
3474
- cwd,
3475
- dir
3476
- );
3477
- },
3478
- async put(key2, data) {
3479
- const path = pathOf(key2);
3480
- await mkdir(dirname(path), { recursive: true });
3481
- await writeFile(path, data, { flag: "wx" });
3482
- },
3483
- async get(key2) {
3484
- const path = pathOf(key2);
3485
- const info = await stat(path).catch(() => void 0);
3486
- if (!info?.isFile()) return null;
3487
- return { body: new Uint8Array(await readFile(path)), size: info.size };
3488
- },
3489
- async delete(key2) {
3490
- await rm(pathOf(key2), { force: true });
3491
- }
3492
- };
3493
- }
3494
-
3495
3844
  // src/versions.ts
3496
3845
  var DEFAULT_MAX_VERSIONS = 50;
3497
3846
  function versionLimit(config) {
@@ -3499,8 +3848,8 @@ function versionLimit(config) {
3499
3848
  if (!versions) return void 0;
3500
3849
  return versions === true ? DEFAULT_MAX_VERSIONS : versions.max ?? DEFAULT_MAX_VERSIONS;
3501
3850
  }
3502
- var collectionParent = (slug) => slug;
3503
- var globalParent = (slug) => `global:${slug}`;
3851
+ var collectionParent = (slug2) => slug2;
3852
+ var globalParent = (slug2) => `global:${slug2}`;
3504
3853
  function snapshotOf(fields, doc) {
3505
3854
  const hidden = new Set(fields.filter((f) => f.hidden).map((f) => f.name));
3506
3855
  const snapshot = {};
@@ -4022,17 +4371,17 @@ var EasyCMS = class {
4022
4371
  return token ? verifyPreviewToken(this.config.secret, token) : null;
4023
4372
  }
4024
4373
  /** Live preview of a global. See `preview`. */
4025
- async previewGlobal(slug, data, options = {}) {
4026
- const config = this.global(slug);
4374
+ async previewGlobal(slug2, data, options = {}) {
4375
+ const config = this.global(slug2);
4027
4376
  const guard = guardOf(options);
4028
4377
  await this.checkGlobalAccess(config, "update", guard);
4029
- const raw = asObject(data, slug);
4378
+ const raw = asObject(data, slug2);
4030
4379
  const input = await filterInput(
4031
4380
  config.fields,
4032
4381
  raw,
4033
4382
  this.fieldChecker("update", guard, void 0, raw)
4034
4383
  );
4035
- const saved = await this.db.findGlobal({ slug }) ?? {};
4384
+ const saved = await this.db.findGlobal({ slug: slug2 }) ?? {};
4036
4385
  const current = await this.globalDraft(config, saved);
4037
4386
  const merged = generateSlugs(
4038
4387
  config.fields,
@@ -4202,11 +4551,11 @@ var EasyCMS = class {
4202
4551
  const [out] = await this.output(config, [existing], guard, { depth: 0, draft: true });
4203
4552
  return out;
4204
4553
  }
4205
- async findGlobal(slug, options = {}) {
4206
- const config = this.global(slug);
4554
+ async findGlobal(slug2, options = {}) {
4555
+ const config = this.global(slug2);
4207
4556
  const guard = guardOf(options);
4208
4557
  await this.checkGlobalAccess(config, "read", guard);
4209
- const saved = await this.db.findGlobal({ slug });
4558
+ const saved = await this.db.findGlobal({ slug: slug2 });
4210
4559
  const stored = saved && options.draft ? await this.globalDraft(config, saved) : saved;
4211
4560
  const data = fillMissing(
4212
4561
  config.fields,
@@ -4220,33 +4569,33 @@ var EasyCMS = class {
4220
4569
  const { id: _id, ...doc } = out;
4221
4570
  return doc;
4222
4571
  }
4223
- async updateGlobal(slug, data, options = {}) {
4224
- return this.saveGlobal(slug, asObject(data, slug), options);
4572
+ async updateGlobal(slug2, data, options = {}) {
4573
+ return this.saveGlobal(slug2, asObject(data, slug2), options);
4225
4574
  }
4226
4575
  /** Takes a global off the site (`status: 'draft'`). See `unpublish`. */
4227
- async unpublishGlobal(slug, options = {}) {
4228
- if (!this.global(slug).drafts) throw new QueryError(`"${slug}" has no drafts`);
4229
- 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");
4230
4579
  }
4231
4580
  /** Throws away the unpublished draft of a published global. */
4232
- async discardGlobalDraft(slug, options = {}) {
4233
- const config = this.global(slug);
4581
+ async discardGlobalDraft(slug2, options = {}) {
4582
+ const config = this.global(slug2);
4234
4583
  const guard = guardOf(options);
4235
4584
  await this.checkGlobalAccess(config, "update", guard);
4236
- const saved = await this.db.findGlobal({ slug });
4585
+ const saved = await this.db.findGlobal({ slug: slug2 });
4237
4586
  if (saved && await this.globalDraft(config, saved) !== saved) {
4238
- await this.saveVersion(config, globalParent(slug), 0, saved, guard);
4587
+ await this.saveVersion(config, globalParent(slug2), 0, saved, guard);
4239
4588
  }
4240
- return this.findGlobal(slug, { ...options, draft: true });
4589
+ return this.findGlobal(slug2, { ...options, draft: true });
4241
4590
  }
4242
4591
  /** Saved versions of a global, newest first. Needs update access. */
4243
- async findGlobalVersions(slug, options = {}) {
4244
- await this.globalVersionTarget(slug, options);
4245
- 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);
4246
4595
  }
4247
- async findGlobalVersion(slug, versionId, options = {}) {
4248
- const config = await this.globalVersionTarget(slug, options);
4249
- 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);
4250
4599
  if (!version) return null;
4251
4600
  const data = fillMissing(
4252
4601
  config.fields,
@@ -4260,21 +4609,21 @@ var EasyCMS = class {
4260
4609
  return { ...version, data: { ...doc, updatedAt: version.createdAt } };
4261
4610
  }
4262
4611
  /** Makes an old version of a global current (as a draft when the global has drafts). */
4263
- async restoreGlobalVersion(slug, versionId, options = {}) {
4264
- const config = await this.globalVersionTarget(slug, options);
4265
- const version = await this.versions.get(globalParent(slug), 0, versionId);
4266
- 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);
4267
4616
  const { status: _status, ...data } = version.data;
4268
4617
  return this.saveGlobal(
4269
- slug,
4618
+ slug2,
4270
4619
  config.drafts ? { ...data, status: "draft" } : data,
4271
4620
  options,
4272
4621
  "restore"
4273
4622
  );
4274
4623
  }
4275
- async globalVersionTarget(slug, options) {
4276
- const config = this.global(slug);
4277
- 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`);
4278
4627
  await this.checkGlobalAccess(config, "update", guardOf(options));
4279
4628
  return config;
4280
4629
  }
@@ -4285,18 +4634,18 @@ var EasyCMS = class {
4285
4634
  if (version?.status !== "draft") return saved;
4286
4635
  return { ...version.data, updatedAt: version.createdAt, status: "draft" };
4287
4636
  }
4288
- async saveGlobal(slug, raw, options, mode = "save") {
4289
- const config = this.global(slug);
4637
+ async saveGlobal(slug2, raw, options, mode = "save") {
4638
+ const config = this.global(slug2);
4290
4639
  const guard = guardOf(options);
4291
4640
  await this.checkGlobalAccess(config, "update", guard);
4292
4641
  if (config.drafts && (mode === "unpublish" || raw.status === "published"))
4293
- this.checkKey(guard, { global: slug }, "publish");
4642
+ this.checkKey(guard, { global: slug2 }, "publish");
4294
4643
  const input = await filterInput(
4295
4644
  config.fields,
4296
4645
  raw,
4297
4646
  this.fieldChecker("update", guard, void 0, raw)
4298
4647
  );
4299
- const existing = await this.db.findGlobal({ slug }) ?? {};
4648
+ const existing = await this.db.findGlobal({ slug: slug2 }) ?? {};
4300
4649
  const current = await this.globalDraft(config, existing);
4301
4650
  const localized = mode === "restore" ? input : this.toMaps(config, input, current, options);
4302
4651
  const merged = generateSlugs(
@@ -4320,26 +4669,26 @@ var EasyCMS = class {
4320
4669
  const now = (/* @__PURE__ */ new Date()).toISOString();
4321
4670
  if (mode !== "unpublish" && this.separateDrafts(config) && existing.status === "published" && prepared.status === "draft") {
4322
4671
  const draft = { ...prepared, updatedAt: now };
4323
- await this.saveVersion(config, globalParent(slug), 0, draft, guard);
4324
- 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, {
4325
4674
  ...base,
4326
4675
  doc: draft,
4327
4676
  previousDoc: current,
4328
4677
  operation: "update"
4329
4678
  });
4330
4679
  this.emit(config, "draft", draft);
4331
- return this.findGlobal(slug, { ...options, draft: true });
4680
+ return this.findGlobal(slug2, { ...options, draft: true });
4332
4681
  }
4333
- const doc = await this.db.updateGlobal({ slug, data: { ...prepared, updatedAt: now } });
4334
- await this.saveVersion(config, globalParent(slug), 0, doc, guard);
4335
- 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, {
4336
4685
  ...base,
4337
4686
  doc,
4338
4687
  previousDoc: current,
4339
4688
  operation: "update"
4340
4689
  });
4341
4690
  this.emit(config, "update", doc, existing.status);
4342
- return this.findGlobal(slug, { ...options, draft: true });
4691
+ return this.findGlobal(slug2, { ...options, draft: true });
4343
4692
  }
4344
4693
  /**
4345
4694
  * What `user` may do with one document. Resolves `where`-style access
@@ -4442,6 +4791,9 @@ var EasyCMS = class {
4442
4791
  async runJobs(now = /* @__PURE__ */ new Date()) {
4443
4792
  const scheduled = await this.runScheduled(now);
4444
4793
  await this.pruneFailedDeliveries(now);
4794
+ await runDueBackups(this, now).catch(
4795
+ (error) => this.logger.error(`Backups: ${error.message}`)
4796
+ );
4445
4797
  return {
4446
4798
  ...scheduled,
4447
4799
  webhooks: await this.retryWebhooks(now),
@@ -4511,9 +4863,9 @@ var EasyCMS = class {
4511
4863
  const action = row.action;
4512
4864
  try {
4513
4865
  if (parent.startsWith("global:")) {
4514
- const slug = parent.slice("global:".length);
4515
- if (action === "publish") await this.saveGlobal(slug, { status: "published" }, {});
4516
- 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");
4517
4869
  } else if (action === "publish") {
4518
4870
  await this.updateDocument(parent, row.doc, { status: "published" }, { depth: 0 });
4519
4871
  } else {
@@ -4557,17 +4909,17 @@ var EasyCMS = class {
4557
4909
  const { parsed } = await this.scheduleTarget(collection2, id, options);
4558
4910
  await this.removeJob(collection2, parsed, jobId);
4559
4911
  }
4560
- async scheduleGlobal(slug, job, options = {}) {
4561
- await this.globalScheduleTarget(slug, options);
4562
- 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);
4563
4915
  }
4564
- async scheduledGlobal(slug, options = {}) {
4565
- await this.globalScheduleTarget(slug, options);
4566
- return this.pendingJobs(`global:${slug}`, 0);
4916
+ async scheduledGlobal(slug2, options = {}) {
4917
+ await this.globalScheduleTarget(slug2, options);
4918
+ return this.pendingJobs(`global:${slug2}`, 0);
4567
4919
  }
4568
- async cancelGlobalSchedule(slug, jobId, options = {}) {
4569
- await this.globalScheduleTarget(slug, options);
4570
- 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);
4571
4923
  }
4572
4924
  async scheduleTarget(collection2, id, options) {
4573
4925
  const config = this.collection(collection2);
@@ -4579,11 +4931,11 @@ var EasyCMS = class {
4579
4931
  this.checkKey(guardOf(options), { collection: collection2 }, "publish");
4580
4932
  return { config, parsed };
4581
4933
  }
4582
- async globalScheduleTarget(slug, options) {
4583
- const config = this.global(slug);
4584
- 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`);
4585
4937
  await this.checkGlobalAccess(config, "update", guardOf(options));
4586
- this.checkKey(guardOf(options), { global: slug }, "publish");
4938
+ this.checkKey(guardOf(options), { global: slug2 }, "publish");
4587
4939
  }
4588
4940
  async addJob(parent, doc, job, options) {
4589
4941
  const runAt = new Date(job.at);
@@ -4717,14 +5069,14 @@ var EasyCMS = class {
4717
5069
  }
4718
5070
  // -------------------------------------------------------------------------
4719
5071
  /** @internal */
4720
- collection(slug) {
4721
- const config = this.config.collections.find((c) => c.slug === slug);
4722
- 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}"`);
4723
5075
  return config;
4724
5076
  }
4725
- global(slug) {
4726
- const config = this.config.globals.find((g) => g.slug === slug);
4727
- 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}"`);
4728
5080
  return config;
4729
5081
  }
4730
5082
  /** Refuses what a request's API key does not allow (see `ApiKeyPermissions`). */
@@ -4974,13 +5326,13 @@ var EasyCMS = class {
4974
5326
  return current;
4975
5327
  }
4976
5328
  /** Runs hooks after the change is saved: failures are logged, never undo the save (NFR-REL-03). */
4977
- async notify(hooks, name, slug, args) {
5329
+ async notify(hooks, name, slug2, args) {
4978
5330
  for (const hook of hooks ?? []) {
4979
5331
  try {
4980
5332
  await hook(args);
4981
5333
  } catch (error) {
4982
5334
  this.logger.error(
4983
- `${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)}`
4984
5336
  );
4985
5337
  }
4986
5338
  }
@@ -5082,9 +5434,9 @@ var EasyCMS = class {
5082
5434
  const value = data[field.name];
5083
5435
  if (field.localized && value && typeof value === "object") {
5084
5436
  const map = { ...value };
5085
- for (const [locale, slug] of Object.entries(map)) {
5086
- if (typeof slug === "string" && slug !== "")
5087
- 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);
5088
5440
  }
5089
5441
  data[field.name] = map;
5090
5442
  } else if (typeof value === "string" && value !== "") {
@@ -5223,11 +5575,11 @@ function asObject(data, name) {
5223
5575
 
5224
5576
  // src/rest/admin-modules.ts
5225
5577
  import { createHash as createHash3 } from "crypto";
5226
- import { readFile as readFile2 } from "fs/promises";
5578
+ import { readFile as readFile3 } from "fs/promises";
5227
5579
  import { createRequire } from "module";
5228
- import { join as join2 } from "path";
5580
+ import { join as join3 } from "path";
5229
5581
  function resolveAdminModule(specifier, cwd = process.cwd()) {
5230
- const bases = [join2(cwd, "package.json")];
5582
+ const bases = [join3(cwd, "package.json")];
5231
5583
  const entry = process.argv[1];
5232
5584
  if (entry && !specifier.startsWith(".")) bases.push(entry);
5233
5585
  for (const base of bases) {
@@ -5261,7 +5613,7 @@ function adminModuleUrls(cms) {
5261
5613
  async function readAdminModule(cms, index) {
5262
5614
  const file = files(cms)[index];
5263
5615
  if (!file) return void 0;
5264
- const body = await readFile2(file, "utf8").catch(() => void 0);
5616
+ const body = await readFile3(file, "utf8").catch(() => void 0);
5265
5617
  if (body === void 0) return void 0;
5266
5618
  return { body, etag: `"${createHash3("sha1").update(body).digest("base64url")}"` };
5267
5619
  }
@@ -5279,7 +5631,7 @@ function parse(text) {
5279
5631
  return {};
5280
5632
  }
5281
5633
  }
5282
- function toAdmin(kind, row) {
5634
+ function toAdmin2(kind, row) {
5283
5635
  const base = {
5284
5636
  id: row.id,
5285
5637
  state: row.state === "failed" ? "failed" : "pending",
@@ -5327,7 +5679,7 @@ async function listDeliveries(cms, kind, state, page) {
5327
5679
  cms.db.count({ collection: collection2, where: { state: { equals: "pending" } } })
5328
5680
  ]);
5329
5681
  return {
5330
- docs: found.docs.map((row) => toAdmin(kind, row)),
5682
+ docs: found.docs.map((row) => toAdmin2(kind, row)),
5331
5683
  totalDocs: found.totalDocs,
5332
5684
  page: found.page,
5333
5685
  totalPages: found.totalPages,
@@ -5468,7 +5820,7 @@ function componentRef(component) {
5468
5820
  async function allowed(access, user) {
5469
5821
  return await evaluateAccess(access, { user }) !== false;
5470
5822
  }
5471
- async function serializeFields(fields, update, localized) {
5823
+ async function serializeFields(fields, update2, localized) {
5472
5824
  const out = [];
5473
5825
  for (const field of fields) {
5474
5826
  if (field.hidden) continue;
@@ -5477,7 +5829,7 @@ async function serializeFields(fields, update, localized) {
5477
5829
  if (field.required) f.required = true;
5478
5830
  if (field.unique) f.unique = true;
5479
5831
  if (field.defaultValue !== void 0) f.defaultValue = field.defaultValue;
5480
- if (!await update.allows(field)) f.readOnly = true;
5832
+ if (!await update2.allows(field)) f.readOnly = true;
5481
5833
  if (field.localized && localized) f.localized = true;
5482
5834
  if (field.position === "sidebar") f.position = "sidebar";
5483
5835
  if (field.admin?.component || field.admin?.after?.length || field.admin?.cell) {
@@ -5524,7 +5876,7 @@ async function serializeFields(fields, update, localized) {
5524
5876
  case "array":
5525
5877
  if (field.minRows !== void 0) f.minRows = field.minRows;
5526
5878
  if (field.maxRows !== void 0) f.maxRows = field.maxRows;
5527
- f.fields = await serializeFields(field.fields, update, localized);
5879
+ f.fields = await serializeFields(field.fields, update2, localized);
5528
5880
  break;
5529
5881
  case "blocks":
5530
5882
  if (field.minRows !== void 0) f.minRows = field.minRows;
@@ -5533,12 +5885,12 @@ async function serializeFields(fields, update, localized) {
5533
5885
  field.blocks.map(async (block) => ({
5534
5886
  slug: block.slug,
5535
5887
  ...block.labels ? { labels: block.labels } : {},
5536
- fields: await serializeFields(block.fields, update, localized)
5888
+ fields: await serializeFields(block.fields, update2, localized)
5537
5889
  }))
5538
5890
  );
5539
5891
  break;
5540
5892
  case "group":
5541
- f.fields = await serializeFields(field.fields, update, localized);
5893
+ f.fields = await serializeFields(field.fields, update2, localized);
5542
5894
  break;
5543
5895
  }
5544
5896
  out.push(f);
@@ -5652,11 +6004,11 @@ async function widgets(cms, user) {
5652
6004
  }
5653
6005
 
5654
6006
  // src/version.ts
5655
- var VERSION = true ? "0.31.0" : "0.0.0-dev";
6007
+ var VERSION = true ? "0.32.0" : "0.0.0-dev";
5656
6008
 
5657
6009
  // src/rest/admin-status.ts
5658
6010
  var DAY = 864e5;
5659
- 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);
5660
6012
  async function adminStatus(cms) {
5661
6013
  const config = cms.config;
5662
6014
  const attention = [];
@@ -5709,6 +6061,8 @@ async function adminStatus(cms) {
5709
6061
  });
5710
6062
  if (count > 0) attention.push({ id: "scheduled", count });
5711
6063
  }
6064
+ const backups = await backupAttention(cms);
6065
+ if (backups) attention.push(backups);
5712
6066
  if (!config.email) attention.push({ id: "no-email" });
5713
6067
  if (!config.serverURL && process.env.NODE_ENV === "production")
5714
6068
  attention.push({ id: "no-server-url" });
@@ -5743,8 +6097,8 @@ async function pickerFilter(cms, url, target, user) {
5743
6097
  const spec = forCollection ?? forGlobal;
5744
6098
  if (spec === null) return void 0;
5745
6099
  const dot = spec.indexOf(".");
5746
- const slug = dot > 0 ? spec.slice(0, dot) : "";
5747
- 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);
5748
6102
  const field = owner ? fieldAt(owner.fields, spec.slice(dot + 1)) : void 0;
5749
6103
  if (field?.type !== "relationship" || field.to !== target)
5750
6104
  throw new QueryError(`filterFor: no relationship to "${target}" at "${spec}"`);
@@ -6092,6 +6446,38 @@ async function route(cms, ctx, method, segments, options) {
6092
6446
  }
6093
6447
  return { body: await cms.runJobs() };
6094
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
+ }
6095
6481
  if (first === "admin" && second === "email") {
6096
6482
  if (!ctx.user) throw new UnauthorizedError();
6097
6483
  if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
@@ -6348,47 +6734,47 @@ async function documentAction(cms, ctx, method, collection2, id, path) {
6348
6734
  }
6349
6735
  throw new HttpError("Not found", 404);
6350
6736
  }
6351
- async function globalAction(cms, ctx, method, slug, path) {
6737
+ async function globalAction(cms, ctx, method, slug2, path) {
6352
6738
  const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
6353
6739
  const depth = parseDepth(ctx.url);
6354
6740
  const [action, versionId, extra] = path;
6355
6741
  if (action === "versions" && versionId === void 0) {
6356
6742
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
6357
- return { body: await cms.findGlobalVersions(slug, { ...access, ...parsePage(ctx.url) }) };
6743
+ return { body: await cms.findGlobalVersions(slug2, { ...access, ...parsePage(ctx.url) }) };
6358
6744
  }
6359
6745
  if (action === "versions" && versionId !== void 0 && extra === void 0) {
6360
6746
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
6361
- const version = await cms.findGlobalVersion(slug, versionId, { ...access, ...depth });
6362
- 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);
6363
6749
  return { body: version };
6364
6750
  }
6365
6751
  if (action === "versions" && versionId !== void 0 && extra === "restore" && path.length === 3) {
6366
6752
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
6367
- return { body: await cms.restoreGlobalVersion(slug, versionId, { ...access, ...depth }) };
6753
+ return { body: await cms.restoreGlobalVersion(slug2, versionId, { ...access, ...depth }) };
6368
6754
  }
6369
6755
  if (action === "schedule" && path.length <= 2) {
6370
6756
  if (path.length === 2) {
6371
6757
  if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
6372
- await cms.cancelGlobalSchedule(slug, versionId, access);
6758
+ await cms.cancelGlobalSchedule(slug2, versionId, access);
6373
6759
  return { body: { message: "Cancelled" } };
6374
6760
  }
6375
- if (method === "GET") return { body: await cms.scheduledGlobal(slug, access) };
6761
+ if (method === "GET") return { body: await cms.scheduledGlobal(slug2, access) };
6376
6762
  if (method !== "POST") throw methodNotAllowed(ctx, "GET, POST");
6377
6763
  const body = await readJson(ctx.request);
6378
- return { status: 201, body: await cms.scheduleGlobal(slug, scheduleJob(body), access) };
6764
+ return { status: 201, body: await cms.scheduleGlobal(slug2, scheduleJob(body), access) };
6379
6765
  }
6380
6766
  if (path.length === 1 && action === "preview") {
6381
6767
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
6382
6768
  const body = await readJson(ctx.request);
6383
- const result = await cms.previewGlobal(slug, body, { ...access, ...depth });
6384
- const token = cms.createPreviewToken({ global: slug });
6769
+ const result = await cms.previewGlobal(slug2, body, { ...access, ...depth });
6770
+ const token = cms.createPreviewToken({ global: slug2 });
6385
6771
  return { body: { ...result, url: result.url && withPreviewToken(result.url, token) } };
6386
6772
  }
6387
6773
  if (path.length === 1 && (action === "unpublish" || action === "discard-draft")) {
6388
6774
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
6389
6775
  const options = { ...access, ...depth };
6390
6776
  return {
6391
- 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)
6392
6778
  };
6393
6779
  }
6394
6780
  throw new HttpError("Not found", 404);
@@ -6721,7 +7107,7 @@ function generateTypes(config) {
6721
7107
  for (const global2 of config.globals) {
6722
7108
  parts.push("", globalInterface(global2, n.globals.get(global2.slug), n));
6723
7109
  }
6724
- 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");
6725
7111
  parts.push(
6726
7112
  "",
6727
7113
  "/** Document type by collection slug. */",
@@ -6820,5 +7206,6 @@ export {
6820
7206
  sniffMimeType,
6821
7207
  validateConfig,
6822
7208
  validateFields,
6823
- verifyPassword
7209
+ verifyPassword,
7210
+ writeBackupFile
6824
7211
  };