@saltcorn/admin-models 1.7.0-alpha.0 → 1.7.0-alpha.1

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 (69) hide show
  1. package/cjs/models/backup-retention.cjs +18 -0
  2. package/cjs/models/file-globs.cjs +18 -0
  3. package/cjs/models/zip-extract.cjs +18 -0
  4. package/cjs/models/zip-format.cjs +18 -0
  5. package/cjs/models/zip-worker.cjs +18 -0
  6. package/cjs/models/zip-writer.cjs +18 -0
  7. package/cjs/models/zip.cjs +18 -0
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/models/backup-retention.d.ts +57 -0
  11. package/dist/models/backup-retention.d.ts.map +1 -0
  12. package/dist/models/backup-retention.js +108 -0
  13. package/dist/models/backup-retention.js.map +1 -0
  14. package/dist/models/backup.d.ts +4 -1
  15. package/dist/models/backup.d.ts.map +1 -1
  16. package/dist/models/backup.js +364 -302
  17. package/dist/models/backup.js.map +1 -1
  18. package/dist/models/file-globs.d.ts +34 -0
  19. package/dist/models/file-globs.d.ts.map +1 -0
  20. package/dist/models/file-globs.js +47 -0
  21. package/dist/models/file-globs.js.map +1 -0
  22. package/dist/models/pack.d.ts +1 -1
  23. package/dist/models/pack.d.ts.map +1 -1
  24. package/dist/models/pack.js +26 -1
  25. package/dist/models/pack.js.map +1 -1
  26. package/dist/models/tenant.d.ts.map +1 -1
  27. package/dist/models/tenant.js +2 -0
  28. package/dist/models/tenant.js.map +1 -1
  29. package/dist/models/zip-extract.d.ts +14 -0
  30. package/dist/models/zip-extract.d.ts.map +1 -0
  31. package/dist/models/zip-extract.js +110 -0
  32. package/dist/models/zip-extract.js.map +1 -0
  33. package/dist/models/zip-format.d.ts +57 -0
  34. package/dist/models/zip-format.d.ts.map +1 -0
  35. package/dist/models/zip-format.js +106 -0
  36. package/dist/models/zip-format.js.map +1 -0
  37. package/dist/models/zip-worker.d.ts +2 -0
  38. package/dist/models/zip-worker.d.ts.map +1 -0
  39. package/dist/models/zip-worker.js +177 -0
  40. package/dist/models/zip-worker.js.map +1 -0
  41. package/dist/models/zip-writer.d.ts +75 -0
  42. package/dist/models/zip-writer.d.ts.map +1 -0
  43. package/dist/models/zip-writer.js +321 -0
  44. package/dist/models/zip-writer.js.map +1 -0
  45. package/dist/models/zip.d.ts +101 -0
  46. package/dist/models/zip.d.ts.map +1 -0
  47. package/dist/models/zip.js +211 -0
  48. package/dist/models/zip.js.map +1 -0
  49. package/dist/tests/backup-retention.test.d.ts +2 -0
  50. package/dist/tests/backup-retention.test.d.ts.map +1 -0
  51. package/dist/tests/backup-retention.test.js +134 -0
  52. package/dist/tests/backup-retention.test.js.map +1 -0
  53. package/dist/tests/backup.test.js +186 -2
  54. package/dist/tests/backup.test.js.map +1 -1
  55. package/dist/tests/file-globs.test.d.ts +2 -0
  56. package/dist/tests/file-globs.test.d.ts.map +1 -0
  57. package/dist/tests/file-globs.test.js +66 -0
  58. package/dist/tests/file-globs.test.js.map +1 -0
  59. package/dist/tests/pack.test.js +14 -3
  60. package/dist/tests/pack.test.js.map +1 -1
  61. package/dist/tests/random.test.js +3 -3
  62. package/dist/tests/random.test.js.map +1 -1
  63. package/dist/tests/tenant.test.js +7 -7
  64. package/dist/tests/tenant.test.js.map +1 -1
  65. package/dist/tests/zip.test.d.ts +2 -0
  66. package/dist/tests/zip.test.d.ts.map +1 -0
  67. package/dist/tests/zip.test.js +319 -0
  68. package/dist/tests/zip.test.js.map +1 -0
  69. package/package.json +15 -9
@@ -13,10 +13,10 @@ import Page from "@saltcorn/data/models/page";
13
13
  import PageGroup from "@saltcorn/data/models/page_group";
14
14
  import Plugin from "@saltcorn/data/models/plugin";
15
15
  import { getMigrationsInDB } from "@saltcorn/data/migrate";
16
- import Zip from "adm-zip";
17
- import { dir } from "tmp-promise";
18
- import { writeFile, mkdir, copyFile, rename, readFile, unlink, readdir, stat, } from "fs/promises";
19
- import { existsSync, readdirSync, statSync, createReadStream } from "fs";
16
+ import { ZipBuilder } from "./zip.js";
17
+ import { extractZip } from "./zip-extract.js";
18
+ import { writeFile, mkdir, mkdtemp, copyFile, readFile, unlink, readdir, rm, stat, } from "fs/promises";
19
+ import { createReadStream, existsSync, readdirSync } from "fs";
20
20
  import { join, basename } from "path";
21
21
  import dateFormatLib from "dateformat";
22
22
  const dateFormat = dateFormatLib; // NodeNext default-import interop for dateformat
@@ -34,7 +34,6 @@ import Model from "@saltcorn/data/models/model";
34
34
  import ModelInstance from "@saltcorn/data/models/model_instance";
35
35
  import EventLog from "@saltcorn/data/models/eventlog";
36
36
  import path from "path";
37
- import { execSync, spawn } from "child_process";
38
37
  import SftpClient from "ssh2-sftp-client";
39
38
  import os from "os";
40
39
  import semver from "semver";
@@ -44,6 +43,8 @@ let _awsS3;
44
43
  const awsS3 = () => _awsS3 || (_awsS3 = require("@aws-sdk/client-s3"));
45
44
  let _awsLibStorage;
46
45
  const awsLibStorage = () => _awsLibStorage || (_awsLibStorage = require("@aws-sdk/lib-storage"));
46
+ import { selectBackupsToDeleteGrouped, } from "./backup-retention.js";
47
+ import { parseGlobList, matchesAnyGlob } from "./file-globs.js";
47
48
  import MetaData from "@saltcorn/data/models/metadata";
48
49
  import lodash from "lodash";
49
50
  const { orderBy } = lodash;
@@ -123,82 +124,91 @@ const create_pack_json = async (withEventLog = false, forSnapshot = false) => {
123
124
  continue;
124
125
  if (state.isFixedConfig(cfg.key))
125
126
  continue;
126
- config[cfg.key] = (db.isSQLite ? JSON.parse(cfg.value) : cfg.value)?.v;
127
+ config[cfg.key] = (db.json_read_returns_string ? JSON.parse(cfg.value) : cfg.value)?.v;
127
128
  }
128
129
  pack.config = config;
129
130
  }
130
131
  return pack;
131
132
  };
132
133
  /**
133
- * @param dirpath
134
+ * @param zip
134
135
  */
135
- const create_pack = async (dirpath) => {
136
+ const add_pack = async (zip) => {
136
137
  const pack = await create_pack_json(getState().getConfig("backup_with_event_log", false));
137
- await writeFile(join(dirpath, "pack.json"), JSON.stringify(pack));
138
+ await zip.addBuffer("pack.json", Buffer.from(JSON.stringify(pack)));
138
139
  };
139
140
  /**
140
- *
141
141
  * @param rows
142
- * @param fnm
142
+ * @returns the rows as CSV, or null if there are no rows
143
143
  */
144
- const create_csv_from_rows = async (rows, fnm) => {
144
+ const csv_from_rows = (rows) => {
145
145
  if (rows.length === 0)
146
- return;
147
- const s = stringify(rows, {
146
+ return null;
147
+ return stringify(rows, {
148
148
  header: true,
149
149
  cast: {
150
150
  date: (value) => value.toISOString(),
151
151
  },
152
152
  });
153
- await writeFile(fnm, s);
154
153
  };
155
- const sanitiseTableName = (nm) => nm.replaceAll("/", "");
156
154
  /**
157
- * @function
158
- * @param {Table} table
159
- * @param {string} dirpath
160
- * @returns {Promise<void>}
155
+ *
156
+ * @param rows
157
+ * @param fnm
161
158
  */
162
- const create_table_json = async (table, dirpath) => {
163
- await table.dump_to_json(join(dirpath, sanitiseTableName(table.name) + ".json"));
159
+ const create_csv_from_rows = async (rows, fnm) => {
160
+ const s = csv_from_rows(rows);
161
+ if (s === null)
162
+ return;
163
+ await writeFile(fnm, s);
164
164
  };
165
+ const sanitiseTableName = (nm) => nm.replaceAll("/", "");
165
166
  /**
167
+ * Add the JSON dump of every non-external table (and its history, if the
168
+ * table is versioned) straight into the archive.
166
169
  * @function
167
- * @param {string} root_dirpath
170
+ * @param {ZipBuilder} zip
168
171
  * @return {Promise<void>}
169
172
  */
170
- const create_table_jsons = async (root_dirpath) => {
171
- const dirpath = join(root_dirpath, "tables");
172
- await mkdir(dirpath, { recursive: true });
173
+ const add_table_jsons = async (zip) => {
173
174
  const tables = await Table.find({});
174
175
  const backup_history = getState().getConfig("backup_history", true);
175
176
  for (const t of tables) {
176
177
  if (!t.external && !t.provider_name) {
177
- await create_table_json(t, dirpath);
178
+ // streamed out of the database and into the archive: a table larger
179
+ // than memory is never assembled anywhere
180
+ await zip.addStream(`tables/${sanitiseTableName(t.name)}.json`, t.dump_to_json_stream());
178
181
  if (t.versioned && backup_history) {
179
- await t.dump_history_to_json(join(dirpath, sanitiseTableName(t.name) + "__history.json"));
182
+ await zip.addStream(`tables/${sanitiseTableName(t.name)}__history.json`, t.dump_history_to_json_stream());
180
183
  }
181
184
  }
182
185
  }
183
186
  };
184
187
  /**
188
+ * Add the uploaded files to the archive. The files are read straight from
189
+ * the file store by the zip worker - they are never copied anywhere first.
185
190
  * @function
186
- * @param {string} root_dirpath
191
+ * @param {ZipBuilder} zip
187
192
  * @returns {Promise<void>}
188
193
  */
189
- const backup_files = async (root_dirpath) => {
194
+ const add_files = async (zip) => {
190
195
  const backup_file_prefix = getState().getConfig("backup_file_prefix");
191
- const dirpath = join(root_dirpath, "files");
192
- await mkdir(dirpath);
196
+ const excludeGlobs = parseGlobList(getState().getConfig("backup_exclude_file_globs", ""));
193
197
  const allFiles = [];
194
198
  const iterFolder = async (folder) => {
195
199
  const files = await File.find(folder ? { folder } : {});
196
200
  for (const f of files) {
197
201
  const base = basename(f.location);
202
+ const relPath = folder ? `${folder}/${base}` : base;
203
+ //exclude files and folders matching the configured globs
204
+ if (matchesAnyGlob(excludeGlobs, relPath, !!f.isDirectory)) {
205
+ getState().log(6, `Excluding ${relPath} from backup`);
206
+ continue;
207
+ }
198
208
  if (f.isDirectory && (await f.is_symlink()))
199
209
  continue;
200
210
  else if (f.isDirectory && !(await f.is_symlink())) {
201
- await mkdir(join(dirpath, f.path_to_serve));
211
+ await zip.addDirectory(`files/${f.path_to_serve}`);
202
212
  await iterFolder(f.path_to_serve);
203
213
  }
204
214
  else {
@@ -207,49 +217,48 @@ const backup_files = async (root_dirpath) => {
207
217
  f.mime_sub === "zip" &&
208
218
  !f.user_id)
209
219
  continue;
210
- await copyFile(f.location, join(dirpath, folder || "", base));
220
+ await zip.addLocalFile(`files/${folder ? `${folder}/` : ""}${base}`, f.location);
211
221
  }
212
222
  f.location = path.join(folder || "", base);
213
223
  allFiles.push(f);
214
224
  }
215
225
  };
216
226
  await iterFolder();
217
- await create_csv_from_rows(allFiles, join(root_dirpath, "files.csv"));
227
+ const csv = csv_from_rows(allFiles);
228
+ if (csv !== null)
229
+ await zip.addBuffer("files.csv", Buffer.from(csv));
218
230
  };
219
- const backup_migrations = async (root_dirpath) => {
231
+ const add_migrations = async (zip) => {
220
232
  const migrations = await getMigrationsInDB();
221
- await writeFile(join(root_dirpath, "migrations.json"), JSON.stringify(migrations));
233
+ await zip.addBuffer("migrations.json", Buffer.from(JSON.stringify(migrations)));
222
234
  };
223
- const backup_metadata = async (root_dirpath) => {
235
+ const add_metadata = async (zip) => {
224
236
  const metadata = await MetaData.find({});
225
- await writeFile(join(root_dirpath, "metadata.json"), JSON.stringify(metadata));
237
+ await zip.addBuffer("metadata.json", Buffer.from(JSON.stringify(metadata)));
226
238
  };
227
239
  /**
228
240
  * @function
229
- * @param {string} root_dirpath
241
+ * @param {ZipBuilder} zip
230
242
  * @returns {Promise<void>}
231
243
  */
232
- const backup_config = async (root_dirpath) => {
233
- const dirpath = join(root_dirpath, "config");
234
- await mkdir(dirpath);
244
+ const add_config = async (zip) => {
235
245
  const cfgs = await db.select("_sc_config");
236
246
  const state = getState();
237
247
  for (const cfg of cfgs) {
238
248
  if (!state.isFixedConfig(cfg.key))
239
- await writeFile(join(dirpath, cfg.key), JSON.stringify(db.isSQLite ? JSON.parse(cfg.value) : cfg.value));
249
+ await zip.addBuffer(`config/${cfg.key}`, Buffer.from(JSON.stringify(db.json_read_returns_string ? JSON.parse(cfg.value) : cfg.value)));
240
250
  }
241
251
  };
242
- const backup_info_file = async (root_dirpath) => {
243
- const state = getState();
252
+ const add_info_file = async (zip) => {
244
253
  const migrations_run = await getMigrationsInDB();
245
254
  const dbversion = await db.getVersion(true);
246
255
  const saltcorn_version = db.connectObj.sc_version;
247
- await writeFile(join(root_dirpath, "backup-info.json"), JSON.stringify({
256
+ await zip.addBuffer("backup-info.json", Buffer.from(JSON.stringify({
248
257
  saltcorn_version,
249
258
  migrations_run,
250
259
  backup_date: new Date().toISOString(),
251
260
  node_version: process.version,
252
- database_type: db.isSQLite ? "SQLite" : "PostgreSQL",
261
+ database_type: db.sql_backend_display_name,
253
262
  database_version: dbversion,
254
263
  os: {
255
264
  platform: os.platform(),
@@ -259,131 +268,118 @@ const backup_info_file = async (root_dirpath) => {
259
268
  type: os.type(),
260
269
  release: os.release(),
261
270
  },
262
- }, null, 2));
263
- };
264
- const zipFolder = async (folder, zipFileName) => {
265
- const backup_with_system_zip = executableIsAvailable("zip");
266
- const backup_password = getState().getConfig("backup_password", "");
267
- if (backup_with_system_zip) {
268
- return await new Promise((resolve, reject) => {
269
- const absZipPath = path.join(process.cwd(), zipFileName);
270
- const args = [
271
- "-5",
272
- "-rq",
273
- ...(backup_password ? [`-P`, backup_password] : []),
274
- absZipPath,
275
- ".",
276
- ];
277
- const subprocess = spawn("zip", args, { cwd: folder });
278
- subprocess.stdout.on("data", (data) => {
279
- getState().log(6, data.toString());
280
- });
281
- subprocess.stderr.on("data", (data) => {
282
- getState().log(1, data.toString());
283
- });
284
- subprocess.on("close", (exitCode) => {
285
- if (exitCode !== 0)
286
- reject(new Error("zip failed"));
287
- else
288
- resolve(undefined);
289
- });
290
- });
291
- }
292
- else {
293
- const zip = new Zip();
294
- zip.addLocalFolder(folder);
295
- zip.writeZip(zipFileName);
296
- }
271
+ }, null, 2)));
297
272
  };
298
273
  /**
299
- * Create backup
300
- * @param fnm
274
+ * The name a backup taken now gets, e.g. sc-backup-Saltcorn-2026-08-14-09-15.zip
301
275
  */
302
- const create_backup = async (fnm) => {
303
- const tmpDir = await dir({ unsafeCleanup: true });
304
- await create_pack(tmpDir.path);
305
- await create_table_jsons(tmpDir.path);
306
- await backup_files(tmpDir.path);
307
- await backup_config(tmpDir.path);
308
- await backup_migrations(tmpDir.path);
309
- await backup_info_file(tmpDir.path);
310
- await backup_metadata(tmpDir.path);
276
+ const backup_file_name = () => {
311
277
  const day = dateFormat(new Date(), "yyyy-mm-dd-HH-MM");
312
278
  const ten = db.getTenantSchema();
313
279
  const tens = ten === db.connectObj.default_schema
314
280
  ? getState().getConfig("site_name", "Saltcorn")
315
281
  : ten;
316
282
  const backup_file_prefix = getState().getConfig("backup_file_prefix");
317
- const zipFileName = fnm || `${backup_file_prefix}${tens}-${day}.zip`;
318
- await zipFolder(tmpDir.path, zipFileName);
319
- await tmpDir.cleanup();
320
- return zipFileName;
283
+ return `${backup_file_prefix}${tens}-${day}.zip`;
321
284
  };
322
- // https://stackoverflow.com/a/74743490/19839414
323
- function executableIsAvailable(name) {
324
- const shell = (cmd) => execSync(cmd, { encoding: "utf8" });
285
+ /**
286
+ * Put everything a backup consists of into the archive. Each entry goes
287
+ * straight in as it is produced - nothing is staged in a temporary directory,
288
+ * and the archive itself is streamed out as it is built, so the memory and
289
+ * the disk a backup needs do not grow with the size of the site.
290
+ */
291
+ const fill_backup = async (zip) => {
292
+ await add_pack(zip);
293
+ await add_table_jsons(zip);
294
+ await add_files(zip);
295
+ await add_config(zip);
296
+ await add_migrations(zip);
297
+ await add_info_file(zip);
298
+ await add_metadata(zip);
299
+ };
300
+ const backup_password = () => getState().getConfig("backup_password", "");
301
+ /**
302
+ * Write a backup to its final location. The archive is written where it
303
+ * belongs, not written somewhere else and moved - moving it would fail
304
+ * across filesystems (Docker volumes, external drives).
305
+ */
306
+ const write_backup_to_file = async (filePath) => {
307
+ const zip = await ZipBuilder.toFile(filePath, backup_password());
325
308
  try {
326
- shell(`which ${name}`);
327
- return true;
309
+ await fill_backup(zip);
328
310
  }
329
- catch (error) {
330
- return false;
311
+ catch (e) {
312
+ await zip.discard(e);
313
+ throw e;
331
314
  }
332
- }
315
+ await zip.finish();
316
+ };
317
+ /**
318
+ * Build a backup and hand it to `consume` as a stream, for destinations
319
+ * written over the network (S3, SFTP) or straight to a browser, where no
320
+ * local copy is wanted. The archive is produced as the consumer reads it,
321
+ * so only a few buffers of it exist at a time.
322
+ */
323
+ const backup_to_stream = async (consume) => {
324
+ const zip = await ZipBuilder.toStream(backup_password());
325
+ // start the consumer first: the archive is only produced as fast as it is
326
+ // read, so filling it before anything reads would deadlock
327
+ const consumed = consume(zip.stream);
328
+ let consumeError;
329
+ // if the consumer gives up - a failed upload, a browser that went away -
330
+ // stop building rather than waiting forever for it to read more
331
+ consumed.catch((e) => {
332
+ consumeError = e;
333
+ zip.discard(e);
334
+ });
335
+ try {
336
+ await fill_backup(zip);
337
+ await zip.finish();
338
+ }
339
+ catch (e) {
340
+ await zip.discard(e);
341
+ // let the consumer settle before reporting: when it was the consumer
342
+ // that failed, its error is the one worth showing
343
+ await consumed.catch(() => { });
344
+ throw consumeError || e;
345
+ }
346
+ return await consumed;
347
+ };
348
+ /**
349
+ * Create backup
350
+ * @param fnm optional file name, otherwise a name based on the site name
351
+ * and the current time
352
+ */
353
+ const create_backup = async (fnm) => {
354
+ const zipFileName = fnm || backup_file_name();
355
+ await write_backup_to_file(zipFileName);
356
+ return zipFileName;
357
+ };
333
358
  /**
359
+ * Extract a zip file into a directory
334
360
  * @function
335
361
  * @param {string} fnm
336
362
  * @param {string} dir
337
363
  * @returns {Promise<void>}
338
364
  */
339
365
  const extract = async (fnm, dir, password) => {
340
- const state = getState();
341
- const backup_with_system_zip = executableIsAvailable("unzip");
342
- if (backup_with_system_zip) {
343
- return await new Promise((resolve, reject) => {
344
- const args = [
345
- ...(password ? [`-P${password}`] : []),
346
- File.normalise(fnm),
347
- "-d",
348
- dir,
349
- ];
350
- const subprocess = spawn("unzip", args);
351
- subprocess.stdout.on("data", (data) => {
352
- state.log(6, data.toString());
353
- });
354
- subprocess.stderr.on("data", (data) => {
355
- const output = data.toString();
356
- state.log(1, output);
357
- if (output.includes("password") || output.includes("encrypted")) {
358
- reject({ requiresPassword: true });
359
- }
360
- });
361
- subprocess.on("close", (exitCode) => {
362
- if (exitCode !== 0)
363
- reject(new Error("unzip failed"));
364
- else
365
- resolve();
366
- });
367
- });
368
- }
369
- else {
370
- const zip = new Zip(fnm);
371
- try {
372
- if (password) {
373
- zip.extractAllTo(dir, true, false, password);
374
- }
375
- else {
376
- zip.extractAllTo(dir, true, false);
377
- }
378
- }
379
- catch (error) {
380
- if (error.message.includes("password") ||
381
- error.message.includes("encrypted")) {
382
- error.requiresPassword = true;
383
- }
384
- throw error;
385
- }
386
- }
366
+ await extractZip(File.normalise(fnm), dir, password);
367
+ };
368
+ /**
369
+ * A scratch directory for a restore, on the same volume as the file store.
370
+ * Deliberately not the OS temporary directory: that is small, or in RAM, on
371
+ * many distributions, while the file store is sized for the app's data.
372
+ */
373
+ const staging_dir = async (prefix) => {
374
+ const base = join(db.connectObj.file_store || process.cwd(), ".saltcorn-tmp");
375
+ await mkdir(base, { recursive: true });
376
+ const dirPath = await mkdtemp(join(base, prefix));
377
+ return {
378
+ path: dirPath,
379
+ cleanup: async () => {
380
+ await rm(dirPath, { recursive: true, force: true });
381
+ },
382
+ };
387
383
  };
388
384
  /**
389
385
  * @function
@@ -467,13 +463,14 @@ const restore_file_users = async (file_users) => {
467
463
  * @param {boolean} [restore_first_user]
468
464
  * @returns {Promise<string|undefined>}
469
465
  */
470
- const restore_tables = async (dirpath, restore_first_user) => {
466
+ const restore_tables = async (dirpath, restore_first_user, onLog) => {
471
467
  let err;
472
468
  const tables = await Table.find();
473
469
  const restore_history = getState().getConfig("restore_history", true);
474
470
  for (const table of tables) {
475
471
  if (!isTest())
476
472
  getState().log(2, `restoring table ${table.name}`);
473
+ onLog?.(`Restoring table ${table.name}`);
477
474
  const fnm_csv = table.name === "users"
478
475
  ? join(dirpath, "users.csv")
479
476
  : join(dirpath, "tables", sanitiseTableName(table.name) + ".csv");
@@ -543,148 +540,174 @@ const restore_metadata = async (dirpath) => {
543
540
  * @param fnm
544
541
  * @param loadAndSaveNewPlugin
545
542
  * @param restore_first_user
543
+ * @param password
544
+ * @param onLog optional callback, called with a short progress message for
545
+ * each restore step and entity type/table - in addition to, not instead
546
+ * of, the usual state.log calls
546
547
  */
547
- const restore = async (fnm, loadAndSaveNewPlugin, restore_first_user, password) => {
548
+ const restore = async (fnm, loadAndSaveNewPlugin, restore_first_user, password, onLog) => {
548
549
  const state = getState();
549
- state.log(2, `Starting restore to tenant ${db.getTenantSchema()}`);
550
- const tmpDir = await dir({ unsafeCleanup: true });
551
- await extract(fnm, tmpDir.path, password);
552
- state.log(2, `Unzip done`);
553
- let basePath = tmpDir.path;
554
- // safari re-compressed. Safari unpacks zip files on download. If the user
555
- // chooses compress in finder, the backup dir is nested inside the zip file
556
- if (!existsSync(join(basePath, "pack.json"))) {
557
- const files = await readdir(basePath);
558
- let found = false;
559
- for (const file of files) {
560
- if (existsSync(join(basePath, file, "pack.json"))) {
561
- basePath = join(basePath, file);
562
- found = true;
563
- break;
550
+ const log = (msg) => {
551
+ if (!isTest())
552
+ state.log(2, msg);
553
+ onLog?.(msg);
554
+ };
555
+ log(`Starting restore to tenant ${db.getTenantSchema()}`);
556
+ const tmpDir = await staging_dir("sc-restore-");
557
+ try {
558
+ await extract(fnm, tmpDir.path, password);
559
+ log(`Unzip done`);
560
+ let basePath = tmpDir.path;
561
+ // safari re-compressed. Safari unpacks zip files on download. If the user
562
+ // chooses compress in finder, the backup dir is nested inside the zip file
563
+ if (!existsSync(join(basePath, "pack.json"))) {
564
+ const files = await readdir(basePath);
565
+ let found = false;
566
+ for (const file of files) {
567
+ if (existsSync(join(basePath, file, "pack.json"))) {
568
+ basePath = join(basePath, file);
569
+ found = true;
570
+ break;
571
+ }
564
572
  }
573
+ if (!found)
574
+ return "Not a valid backup file";
565
575
  }
566
- if (!found)
567
- return "Not a valid backup file";
568
- }
569
- let err;
570
- if (existsSync(join(basePath, "backup-info.json"))) {
571
- const info = JSON.parse((await readFile(join(basePath, "backup-info.json"))).toString());
572
- const saltcorn_version = db.connectObj.sc_version;
573
- if (info.saltcorn_version &&
574
- semver.gt(info.saltcorn_version, saltcorn_version)) {
575
- err = `Warning: backup is from a more recent version (${info.saltcorn_version}) than the installed version (${saltcorn_version}). `;
576
+ let err;
577
+ if (existsSync(join(basePath, "backup-info.json"))) {
578
+ const info = JSON.parse((await readFile(join(basePath, "backup-info.json"))).toString());
579
+ const saltcorn_version = db.connectObj.sc_version;
580
+ if (info.saltcorn_version &&
581
+ semver.gt(info.saltcorn_version, saltcorn_version)) {
582
+ err = `Warning: backup is from a more recent version (${info.saltcorn_version}) than the installed version (${saltcorn_version}). `;
583
+ }
576
584
  }
577
- }
578
- //install pack
579
- if (!isTest())
580
- state.log(2, `Reading pack`);
581
- const pack = JSON.parse((await readFile(join(basePath, "pack.json"))).toString());
582
- const can_restore = await can_install_pack(pack);
583
- if (typeof can_restore !== "boolean" && can_restore.error) {
584
- return `Cannot restore backup, clashing entities:
585
+ //install pack
586
+ log(`Reading pack`);
587
+ const pack = JSON.parse((await readFile(join(basePath, "pack.json"))).toString());
588
+ const can_restore = await can_install_pack(pack);
589
+ if (typeof can_restore !== "boolean" && can_restore.error) {
590
+ return `Cannot restore backup, clashing entities:
585
591
  ${can_restore.error || ""}
586
592
  Delete these entities or restore to a pristine instance.
587
593
  `;
594
+ }
595
+ //config
596
+ log(`Restoring config`);
597
+ await restore_config(basePath);
598
+ log(`Restoring pack`);
599
+ await install_pack(pack, undefined, loadAndSaveNewPlugin, true, log);
600
+ // files
601
+ log(`Restoring files`);
602
+ const { file_users, newLocations } = await restore_files(basePath);
603
+ //table csvs
604
+ log(`Restoring tables`);
605
+ const tabres = await restore_tables(basePath, restore_first_user, log);
606
+ if (tabres)
607
+ err = (err || "") + tabres;
608
+ log(`Restoring metadata`);
609
+ await restore_metadata(basePath);
610
+ if (Object.keys(newLocations).length > 0)
611
+ await correct_fileid_references_to_location(newLocations);
612
+ await restore_file_users(file_users);
613
+ state.log(2, `Completed restore to tenant ${db.getTenantSchema()}${err ? ` with errors ${err}` : " successfully"}`);
614
+ return err;
615
+ }
616
+ finally {
617
+ await tmpDir.cleanup();
588
618
  }
589
- //config
590
- if (!isTest())
591
- state.log(2, `Restoring config`);
592
- await restore_config(basePath);
593
- if (!isTest())
594
- state.log(2, `Restoring pack`);
595
- await install_pack(pack, undefined, loadAndSaveNewPlugin, true);
596
- // files
597
- if (!isTest())
598
- state.log(2, `Restoring files`);
599
- const { file_users, newLocations } = await restore_files(basePath);
600
- //table csvs
601
- if (!isTest())
602
- state.log(2, `Restoring tables`);
603
- const tabres = await restore_tables(basePath, restore_first_user);
604
- if (tabres)
605
- err = (err || "") + tabres;
606
- if (!isTest())
607
- state.log(2, `Restoring metadata`);
608
- await restore_metadata(basePath);
609
- if (Object.keys(newLocations).length > 0)
610
- await correct_fileid_references_to_location(newLocations);
611
- await restore_file_users(file_users);
612
- await tmpDir.cleanup();
613
- state.log(2, `Completed restore to tenant ${db.getTenantSchema()}${err ? ` with errors ${err}` : " successfully"}`);
614
- return err;
615
619
  };
616
620
  /**
617
- * Delete old backups
621
+ * Configuration object shared by the S3 upload and the S3 retention client.
622
+ */
623
+ const s3ClientConfig = (state) => {
624
+ const s3EndpointCfg = state.getConfig("backup_s3_endpoint");
625
+ const s3Secure = state.getConfig("backup_s3_secure", true);
626
+ const endpoint = s3EndpointCfg
627
+ ? /:\/\//.test(s3EndpointCfg)
628
+ ? s3EndpointCfg
629
+ : `${s3Secure ? "https" : "http"}://${s3EndpointCfg}`
630
+ : undefined;
631
+ return {
632
+ credentials: {
633
+ accessKeyId: state.getConfig("backup_s3_access_key"),
634
+ secretAccessKey: state.getConfig("backup_s3_access_secret"),
635
+ },
636
+ region: state.getConfig("backup_s3_region"),
637
+ ...(endpoint ? { endpoint, forcePathStyle: true } : {}),
638
+ };
639
+ };
640
+ const buildS3Client = (state) => new (awsS3().S3Client)(s3ClientConfig(state));
641
+ /**
642
+ * The tiered (GFS) retention policy, or null if retention is by expiration age
643
+ */
644
+ const getRetentionPolicy = () => {
645
+ const mode = getState().getConfig("auto_backup_retention_mode", "Expiration");
646
+ if (mode !== "Tiered (GFS)")
647
+ return null;
648
+ return {
649
+ keep_daily: +getState().getConfig("auto_backup_keep_daily", 7),
650
+ keep_weekly: +getState().getConfig("auto_backup_keep_weekly", 4),
651
+ keep_monthly: +getState().getConfig("auto_backup_keep_monthly", 12),
652
+ keep_yearly: +getState().getConfig("auto_backup_keep_yearly", 3),
653
+ keep_min: +getState().getConfig("auto_backup_keep_min", 3),
654
+ };
655
+ };
656
+ /**
657
+ * Delete old backups: collect the existing backups at the destination,
658
+ * classify them according to the configured retention policy (expiration
659
+ * age or tiered GFS), then delete those not retained.
618
660
  */
619
661
  const delete_old_backups = async () => {
620
662
  const directory = getState().getConfig("auto_backup_directory");
621
663
  const expire_days = getState().getConfig("auto_backup_expire_days");
622
664
  const backup_file_prefix = getState().getConfig("backup_file_prefix");
623
665
  const destination = getState().getConfig("auto_backup_destination");
624
- if (!expire_days || expire_days < 0)
666
+ const retention = getRetentionPolicy();
667
+ // nothing to do: neither expiration nor tiered retention configured
668
+ if (!retention && (!expire_days || expire_days < 0))
625
669
  return;
670
+ // ---- 1. collect --------------------------------------------------------
671
+ const entries = [];
672
+ let s3;
673
+ let bucket = "";
626
674
  if (destination === "Local directory") {
627
675
  const files = await readdir(directory);
628
676
  for (const file of files) {
629
677
  if (!file.startsWith(backup_file_prefix))
630
678
  continue;
679
+ if (!file.endsWith(".zip"))
680
+ continue;
631
681
  const stats = await stat(path.join(directory, file));
632
682
  const fileTime = stats.birthtime?.getTime
633
683
  ? stats.birthtime.getTime()
634
684
  : stats.mtime.getTime();
635
- const ageMs = new Date().getTime() - fileTime;
636
- const ageDays = ageMs / (1000 * 3600 * 24);
637
- if (ageDays > expire_days)
638
- await unlink(path.join(directory, file));
685
+ entries.push({ key: file, date: new Date(fileTime) });
639
686
  }
640
687
  }
641
688
  else if (destination === "S3") {
642
- const s3EndpointCfg = getState().getConfig("backup_s3_endpoint");
643
- const s3Secure = getState().getConfig("backup_s3_secure", true);
644
- const endpoint = s3EndpointCfg
645
- ? /:\/\//.test(s3EndpointCfg)
646
- ? s3EndpointCfg
647
- : `${s3Secure ? "https" : "http"}://${s3EndpointCfg}`
648
- : undefined;
649
- const s3 = new (awsS3().S3Client)({
650
- credentials: {
651
- accessKeyId: getState().getConfig("backup_s3_access_key"),
652
- secretAccessKey: getState().getConfig("backup_s3_access_secret"),
653
- },
654
- region: getState().getConfig("backup_s3_region"),
655
- ...(endpoint ? { endpoint } : {}),
656
- });
657
- const bucket = getState().getConfig("backup_s3_bucket");
689
+ s3 = buildS3Client(getState());
690
+ bucket = getState().getConfig("backup_s3_bucket");
658
691
  const keyPrefix = (getState().getConfig("backup_s3_path_prefix", "") || "")
659
692
  .toString()
660
693
  .replace(/^\/+|\/+$/g, "");
661
694
  const listPrefix = [keyPrefix, backup_file_prefix]
662
695
  .filter((s) => s && s.length)
663
696
  .join("/");
664
- const listParams = {
665
- Bucket: bucket,
666
- Prefix: listPrefix,
667
- };
668
697
  try {
669
698
  let continuationToken = undefined;
670
699
  do {
671
700
  const listedObjects = await s3.send(new (awsS3().ListObjectsV2Command)({
672
- ...listParams,
701
+ Bucket: bucket,
702
+ Prefix: listPrefix,
673
703
  ContinuationToken: continuationToken,
674
704
  }));
675
- if (listedObjects.Contents) {
676
- for (const obj of listedObjects.Contents) {
677
- if (!obj.Key || !obj.LastModified)
678
- continue;
679
- const ageMs = new Date().getTime() - new Date(obj.LastModified).getTime();
680
- const ageDays = ageMs / (1000 * 3600 * 24);
681
- if (ageDays > expire_days) {
682
- await s3.send(new (awsS3().DeleteObjectCommand)({
683
- Bucket: bucket,
684
- Key: obj.Key,
685
- }));
686
- }
687
- }
705
+ for (const obj of listedObjects.Contents || []) {
706
+ if (!obj.Key || !obj.LastModified)
707
+ continue;
708
+ if (!obj.Key.endsWith(".zip"))
709
+ continue;
710
+ entries.push({ key: obj.Key, date: new Date(obj.LastModified) });
688
711
  }
689
712
  continuationToken = listedObjects.IsTruncated
690
713
  ? listedObjects.NextContinuationToken
@@ -692,7 +715,40 @@ const delete_old_backups = async () => {
692
715
  } while (continuationToken);
693
716
  }
694
717
  catch (err) {
695
- console.error(`Error deleting old backups from S3: ${err.message}`);
718
+ console.error(`Error listing old backups in S3: ${err.message}`);
719
+ return;
720
+ }
721
+ }
722
+ else
723
+ return; // Saltcorn files / SFTP: no retention
724
+ // guard against acting on a misconfigured prefix
725
+ if (entries.length === 0)
726
+ return;
727
+ // ---- 2. classify -------------------------------------------------------
728
+ let toDelete;
729
+ if (retention) {
730
+ toDelete = selectBackupsToDeleteGrouped(entries, retention);
731
+ getState().log(5, `Backup retention (GFS): ${entries.length} found, ` +
732
+ `${entries.length - toDelete.length} kept, ${toDelete.length} to delete`);
733
+ }
734
+ else {
735
+ const now = new Date().getTime();
736
+ toDelete = entries.filter((e) => (now - e.date.getTime()) / (1000 * 3600 * 24) > expire_days);
737
+ }
738
+ // ---- 3. delete ---------------------------------------------------------
739
+ for (const entry of toDelete) {
740
+ try {
741
+ if (destination === "Local directory")
742
+ await unlink(path.join(directory, entry.key));
743
+ else
744
+ await s3.send(new (awsS3().DeleteObjectCommand)({
745
+ Bucket: bucket,
746
+ Key: entry.key,
747
+ }));
748
+ getState().log(6, `Deleted expired backup ${entry.key}`);
749
+ }
750
+ catch (err) {
751
+ console.error(`Error deleting old backup ${entry.key}: ${err.message}`);
696
752
  }
697
753
  }
698
754
  };
@@ -700,22 +756,22 @@ const delete_old_backups = async () => {
700
756
  * Do autobackup now
701
757
  */
702
758
  const auto_backup_now_tenant = async (state) => {
703
- state.log(6, `Creating backup file`);
704
- const fileName = await create_backup();
705
- state.log(6, `Created backup file with name ${fileName}`);
706
759
  const destination = state.getConfig("auto_backup_destination", "Saltcorn files");
707
760
  const directory = state.getConfig("auto_backup_directory", "");
708
761
  if (directory === null)
709
762
  throw new Error("Directory is unspecified");
710
- state.log(6, `Backup to destination`);
763
+ // the backup is built straight into its destination, so decide where that
764
+ // is before building it
765
+ const fileName = backup_file_name();
766
+ state.log(6, `Creating backup ${fileName} for destination ${destination}`);
711
767
  switch (destination) {
712
- case "Saltcorn files":
768
+ case "Saltcorn files": {
713
769
  if (directory.length > 0) {
714
770
  await File.new_folder(directory);
715
771
  }
716
772
  const newPath = File.get_new_path(join(directory, fileName));
717
- const stats = statSync(fileName);
718
- await copyFile(fileName, newPath);
773
+ await write_backup_to_file(newPath);
774
+ const stats = await stat(newPath);
719
775
  await File.create({
720
776
  filename: fileName,
721
777
  location: newPath,
@@ -725,71 +781,73 @@ const auto_backup_now_tenant = async (state) => {
725
781
  mime_sub: "zip",
726
782
  min_role_read: 1,
727
783
  });
728
- await unlink(fileName);
729
784
  break;
730
- case "Local directory":
731
- //const directory = state.getConfig("auto_backup_directory");
785
+ }
786
+ case "Local directory": {
732
787
  if (directory.length > 0) {
733
788
  await mkdir(directory, { recursive: true });
734
789
  }
735
- await rename(fileName, join(directory, fileName));
790
+ // written in place rather than written elsewhere and renamed: a rename
791
+ // into a Docker volume or an external drive fails with EXDEV
792
+ await write_backup_to_file(join(directory, fileName));
736
793
  await delete_old_backups();
737
794
  break;
738
- case "SFTP server":
739
- let sftp = new SftpClient();
740
- await sftp.connect({
741
- host: state.getConfig("auto_backup_server"),
742
- port: state.getConfig("auto_backup_port"),
743
- username: state.getConfig("auto_backup_username"),
744
- password: state.getConfig("auto_backup_password"),
745
- });
746
- let data = createReadStream(fileName);
747
- let remote = join(state.getConfig("auto_backup_directory", ""), basename(fileName));
748
- const putres = await sftp.put(data, remote);
749
- state.log(6, `SFTP Put response: ${putres}`);
750
- await sftp.end();
795
+ }
796
+ case "SFTP server": {
797
+ const remote = join(state.getConfig("auto_backup_directory", ""), fileName);
751
798
  const retain_dir = state.getConfig("auto_backup_retain_local_directory");
799
+ const upload = async (body) => {
800
+ const sftp = new SftpClient();
801
+ await sftp.connect({
802
+ host: state.getConfig("auto_backup_server"),
803
+ port: state.getConfig("auto_backup_port"),
804
+ username: state.getConfig("auto_backup_username"),
805
+ password: state.getConfig("auto_backup_password"),
806
+ });
807
+ try {
808
+ const putres = await sftp.put(body, remote);
809
+ state.log(6, `SFTP Put response: ${putres}`);
810
+ }
811
+ finally {
812
+ await sftp.end();
813
+ }
814
+ };
752
815
  if (retain_dir) {
816
+ // a local copy is wanted, so write that first and send it from
817
+ // there rather than holding the archive in memory to do both
753
818
  await mkdir(retain_dir, { recursive: true });
754
- await copyFile(fileName, join(retain_dir, fileName));
819
+ const retained = join(retain_dir, fileName);
820
+ await write_backup_to_file(retained);
821
+ await upload(createReadStream(retained));
822
+ }
823
+ else {
824
+ // straight to the server: the archive is never on the local disk
825
+ await backup_to_stream(upload);
755
826
  }
756
- await unlink(fileName);
757
827
  break;
758
- case "S3":
759
- const bEndpointCfg = state.getConfig("backup_s3_endpoint");
760
- const bSecure = state.getConfig("backup_s3_secure", true);
761
- const bEndpoint = bEndpointCfg
762
- ? /:\/\//.test(bEndpointCfg)
763
- ? bEndpointCfg
764
- : `${bSecure ? "https" : "http"}://${bEndpointCfg}`
765
- : undefined;
766
- const s3 = new (awsS3().S3)({
767
- credentials: {
768
- accessKeyId: state.getConfig("backup_s3_access_key"),
769
- secretAccessKey: state.getConfig("backup_s3_access_secret"),
770
- },
771
- region: state.getConfig("backup_s3_region"),
772
- ...(bEndpoint ? { endpoint: bEndpoint } : {}),
773
- });
828
+ }
829
+ case "S3": {
830
+ const s3 = new (awsS3().S3)(s3ClientConfig(state));
774
831
  const bucket = state.getConfig("backup_s3_bucket");
775
832
  const pathPrefix = (state.getConfig("backup_s3_path_prefix", "") || "")
776
833
  .toString()
777
834
  .replace(/^\/+|\/+$/g, "");
778
- const s3Key = [pathPrefix, basename(fileName)]
835
+ const s3Key = [pathPrefix, fileName]
779
836
  .filter((s) => s && s.length)
780
837
  .join("/");
781
- const fileStream = () => createReadStream(fileName);
782
838
  try {
783
- const uploadResult = await new (awsLibStorage().Upload)({
839
+ // streamed to the bucket as it is built, in multipart chunks: the
840
+ // archive is neither held in memory nor written to the local disk
841
+ const uploadResult = await backup_to_stream(async (body) => await new (awsLibStorage().Upload)({
784
842
  client: s3,
785
843
  params: {
786
844
  Bucket: bucket,
787
845
  Key: s3Key,
788
- Body: fileStream(),
846
+ Body: body,
789
847
  ACL: "private",
790
848
  ContentType: "application/zip",
791
849
  },
792
- }).done();
850
+ }).done());
793
851
  if (uploadResult.$metadata.httpStatusCode !== 200) {
794
852
  throw new Error(`S3 Upload failed with status code ${uploadResult.$metadata.httpStatusCode}`);
795
853
  }
@@ -800,9 +858,11 @@ const auto_backup_now_tenant = async (state) => {
800
858
  }
801
859
  await delete_old_backups();
802
860
  break;
861
+ }
803
862
  default:
804
863
  throw new Error("Unknown destination: " + destination);
805
864
  }
865
+ state.log(6, `Backup ${fileName} written to destination ${destination}`);
806
866
  };
807
867
  const auto_backup_now = async () => {
808
868
  const isRoot = db.getTenantSchema() === db.connectObj.default_schema;
@@ -849,6 +909,8 @@ const auto_backup_now = async () => {
849
909
  };
850
910
  export default {
851
911
  create_backup,
912
+ backup_to_stream,
913
+ backup_file_name,
852
914
  restore,
853
915
  create_csv_from_rows,
854
916
  auto_backup_now,