@saltcorn/admin-models 1.6.3-beta.1 → 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 (99) hide show
  1. package/cjs/index.cjs +18 -0
  2. package/cjs/models/backup-retention.cjs +18 -0
  3. package/cjs/models/backup.cjs +18 -0
  4. package/cjs/models/config-check.cjs +18 -0
  5. package/cjs/models/file-globs.cjs +18 -0
  6. package/cjs/models/pack.cjs +18 -0
  7. package/cjs/models/snapshot.cjs +18 -0
  8. package/cjs/models/tenant.cjs +18 -0
  9. package/cjs/models/zip-extract.cjs +18 -0
  10. package/cjs/models/zip-format.cjs +18 -0
  11. package/cjs/models/zip-worker.cjs +18 -0
  12. package/cjs/models/zip-writer.cjs +18 -0
  13. package/cjs/models/zip.cjs +18 -0
  14. package/dist/index.d.ts +6 -5
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +12 -18
  17. package/dist/index.js.map +1 -1
  18. package/dist/models/backup-retention.d.ts +57 -0
  19. package/dist/models/backup-retention.d.ts.map +1 -0
  20. package/dist/models/backup-retention.js +108 -0
  21. package/dist/models/backup-retention.js.map +1 -0
  22. package/dist/models/backup.d.ts +7 -3
  23. package/dist/models/backup.d.ts.map +1 -1
  24. package/dist/models/backup.js +479 -409
  25. package/dist/models/backup.js.map +1 -1
  26. package/dist/models/config-check.d.ts +2 -1
  27. package/dist/models/config-check.d.ts.map +1 -1
  28. package/dist/models/config-check.js +16 -23
  29. package/dist/models/config-check.js.map +1 -1
  30. package/dist/models/file-globs.d.ts +34 -0
  31. package/dist/models/file-globs.d.ts.map +1 -0
  32. package/dist/models/file-globs.js +47 -0
  33. package/dist/models/file-globs.js.map +1 -0
  34. package/dist/models/pack.d.ts +3 -2
  35. package/dist/models/pack.d.ts.map +1 -1
  36. package/dist/models/pack.js +147 -151
  37. package/dist/models/pack.js.map +1 -1
  38. package/dist/models/snapshot.d.ts +1 -1
  39. package/dist/models/snapshot.d.ts.map +1 -1
  40. package/dist/models/snapshot.js +26 -29
  41. package/dist/models/snapshot.js.map +1 -1
  42. package/dist/models/tenant.d.ts +1 -1
  43. package/dist/models/tenant.d.ts.map +1 -1
  44. package/dist/models/tenant.js +45 -48
  45. package/dist/models/tenant.js.map +1 -1
  46. package/dist/models/zip-extract.d.ts +14 -0
  47. package/dist/models/zip-extract.d.ts.map +1 -0
  48. package/dist/models/zip-extract.js +110 -0
  49. package/dist/models/zip-extract.js.map +1 -0
  50. package/dist/models/zip-format.d.ts +57 -0
  51. package/dist/models/zip-format.d.ts.map +1 -0
  52. package/dist/models/zip-format.js +106 -0
  53. package/dist/models/zip-format.js.map +1 -0
  54. package/dist/models/zip-worker.d.ts +2 -0
  55. package/dist/models/zip-worker.d.ts.map +1 -0
  56. package/dist/models/zip-worker.js +177 -0
  57. package/dist/models/zip-worker.js.map +1 -0
  58. package/dist/models/zip-writer.d.ts +75 -0
  59. package/dist/models/zip-writer.d.ts.map +1 -0
  60. package/dist/models/zip-writer.js +321 -0
  61. package/dist/models/zip-writer.js.map +1 -0
  62. package/dist/models/zip.d.ts +101 -0
  63. package/dist/models/zip.d.ts.map +1 -0
  64. package/dist/models/zip.js +211 -0
  65. package/dist/models/zip.js.map +1 -0
  66. package/dist/tests/backup-retention.test.d.ts +2 -0
  67. package/dist/tests/backup-retention.test.d.ts.map +1 -0
  68. package/dist/tests/backup-retention.test.js +134 -0
  69. package/dist/tests/backup-retention.test.js.map +1 -0
  70. package/dist/tests/backup.test.d.ts +2 -0
  71. package/dist/tests/backup.test.d.ts.map +1 -0
  72. package/dist/tests/backup.test.js +385 -0
  73. package/dist/tests/backup.test.js.map +1 -0
  74. package/dist/tests/cfgcheck.test.d.ts +2 -0
  75. package/dist/tests/cfgcheck.test.d.ts.map +1 -0
  76. package/dist/tests/cfgcheck.test.js +32 -0
  77. package/dist/tests/cfgcheck.test.js.map +1 -0
  78. package/dist/tests/file-globs.test.d.ts +2 -0
  79. package/dist/tests/file-globs.test.d.ts.map +1 -0
  80. package/dist/tests/file-globs.test.js +66 -0
  81. package/dist/tests/file-globs.test.js.map +1 -0
  82. package/dist/tests/pack.test.d.ts +2 -0
  83. package/dist/tests/pack.test.d.ts.map +1 -0
  84. package/dist/tests/pack.test.js +780 -0
  85. package/dist/tests/pack.test.js.map +1 -0
  86. package/dist/tests/random.test.d.ts +2 -0
  87. package/dist/tests/random.test.d.ts.map +1 -0
  88. package/dist/tests/random.test.js +150 -0
  89. package/dist/tests/random.test.js.map +1 -0
  90. package/dist/tests/tenant.test.d.ts +2 -0
  91. package/dist/tests/tenant.test.d.ts.map +1 -0
  92. package/dist/tests/tenant.test.js +106 -0
  93. package/dist/tests/tenant.test.js.map +1 -0
  94. package/dist/tests/zip.test.d.ts +2 -0
  95. package/dist/tests/zip.test.d.ts.map +1 -0
  96. package/dist/tests/zip.test.js +319 -0
  97. package/dist/tests/zip.test.js.map +1 -0
  98. package/gen-cjs-shims.cjs +71 -0
  99. package/package.json +39 -29
@@ -1,88 +1,94 @@
1
- "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- const { getState } = require("@saltcorn/data/db/state");
6
- const index_1 = __importDefault(require("@saltcorn/data/db/index"));
7
- const table_1 = __importDefault(require("@saltcorn/data/models/table"));
8
- const common_types_1 = require("@saltcorn/types/common_types");
9
- const view_1 = __importDefault(require("@saltcorn/data/models/view"));
10
- const file_1 = __importDefault(require("@saltcorn/data/models/file"));
11
- const crash_1 = __importDefault(require("@saltcorn/data/models/crash"));
12
- const field_1 = __importDefault(require("@saltcorn/data/models/field"));
13
- const role_1 = __importDefault(require("@saltcorn/data/models/role"));
14
- const page_1 = __importDefault(require("@saltcorn/data/models/page"));
15
- const page_group_1 = __importDefault(require("@saltcorn/data/models/page_group"));
16
- const plugin_1 = __importDefault(require("@saltcorn/data/models/plugin"));
17
- const migrate_1 = require("@saltcorn/data/migrate");
18
- const adm_zip_1 = __importDefault(require("adm-zip"));
19
- const tmp_promise_1 = require("tmp-promise");
20
- const promises_1 = require("fs/promises");
21
- const fs_1 = require("fs");
22
- const path_1 = require("path");
23
- const dateformat_1 = __importDefault(require("dateformat"));
24
- const sync_1 = require("csv-stringify/sync");
25
- const csvtojson_1 = __importDefault(require("csvtojson"));
26
- const pack_1 = __importDefault(require("./pack"));
27
- const { table_pack, view_pack, plugin_pack, page_pack, page_group_pack, tag_pack, model_instance_pack, event_log_pack, install_pack, can_install_pack, trigger_pack, } = pack_1.default;
28
- const config_1 = __importDefault(require("@saltcorn/data/models/config"));
29
- const { configTypes } = config_1.default;
30
- const { asyncMap, isTest } = require("@saltcorn/data/utils");
31
- const trigger_1 = __importDefault(require("@saltcorn/data/models/trigger"));
32
- const library_1 = __importDefault(require("@saltcorn/data/models/library"));
33
- const tag_1 = __importDefault(require("@saltcorn/data/models/tag"));
34
- const model_1 = __importDefault(require("@saltcorn/data/models/model"));
35
- const model_instance_1 = __importDefault(require("@saltcorn/data/models/model_instance"));
36
- const eventlog_1 = __importDefault(require("@saltcorn/data/models/eventlog"));
37
- const path_2 = __importDefault(require("path"));
38
- const { exec, execSync, spawn } = require("child_process");
39
- const ssh2_sftp_client_1 = __importDefault(require("ssh2-sftp-client"));
40
- const os = require("os");
41
- const semver = require("semver");
42
- const client_s3_1 = require("@aws-sdk/client-s3");
43
- const lib_storage_1 = require("@aws-sdk/lib-storage");
44
- const metadata_1 = __importDefault(require("@saltcorn/data/models/metadata"));
1
+ import { createRequire } from "module";
2
+ const require = createRequire(import.meta.url);
3
+ import { getState } from "@saltcorn/data/db/state";
4
+ import db from "@saltcorn/data/db/index";
5
+ import Table from "@saltcorn/data/models/table";
6
+ import { instanceOfErrorMsg } from "@saltcorn/types/common_types";
7
+ import View from "@saltcorn/data/models/view";
8
+ import File from "@saltcorn/data/models/file";
9
+ import Crash from "@saltcorn/data/models/crash";
10
+ import Field from "@saltcorn/data/models/field";
11
+ import Role from "@saltcorn/data/models/role";
12
+ import Page from "@saltcorn/data/models/page";
13
+ import PageGroup from "@saltcorn/data/models/page_group";
14
+ import Plugin from "@saltcorn/data/models/plugin";
15
+ import { getMigrationsInDB } from "@saltcorn/data/migrate";
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
+ import { join, basename } from "path";
21
+ import dateFormatLib from "dateformat";
22
+ const dateFormat = dateFormatLib; // NodeNext default-import interop for dateformat
23
+ import { stringify } from "csv-stringify/sync";
24
+ import csvtojson from "csvtojson";
25
+ import pack from "./pack.js";
26
+ const { table_pack, view_pack, plugin_pack, page_pack, page_group_pack, tag_pack, model_instance_pack, event_log_pack, install_pack, can_install_pack, trigger_pack, } = pack;
27
+ import * as config from "@saltcorn/data/models/config";
28
+ const { configTypes } = config;
29
+ import { asyncMap, isTest } from "@saltcorn/data/utils";
30
+ import Trigger from "@saltcorn/data/models/trigger";
31
+ import Library from "@saltcorn/data/models/library";
32
+ import Tag from "@saltcorn/data/models/tag";
33
+ import Model from "@saltcorn/data/models/model";
34
+ import ModelInstance from "@saltcorn/data/models/model_instance";
35
+ import EventLog from "@saltcorn/data/models/eventlog";
36
+ import path from "path";
37
+ import SftpClient from "ssh2-sftp-client";
38
+ import os from "os";
39
+ import semver from "semver";
40
+ // @aws-sdk is heavy to load (~50ms) and only used when backing up to S3,
41
+ // so require it lazily rather than at module load time.
42
+ let _awsS3;
43
+ const awsS3 = () => _awsS3 || (_awsS3 = require("@aws-sdk/client-s3"));
44
+ let _awsLibStorage;
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";
48
+ import MetaData from "@saltcorn/data/models/metadata";
49
+ import lodash from "lodash";
50
+ const { orderBy } = lodash;
45
51
  /**
46
52
  * @param [withEventLog] - include event log
47
53
  */
48
54
  const create_pack_json = async (withEventLog = false, forSnapshot = false) => {
49
55
  const state = getState();
50
56
  // tables
51
- const tables = await asyncMap(await table_1.default.find({}, { orderBy: "id" }), async (t) => await table_pack(t) // find already done before
57
+ const tables = await asyncMap(await Table.find({}, { orderBy: "id" }), async (t) => await table_pack(t) // find already done before
52
58
  );
53
59
  // views
54
- const views = await asyncMap((await view_1.default.find({})).filter((v) => v.id), async (v) => await view_pack(v.name));
60
+ const views = await asyncMap((await View.find({})).filter((v) => v.id), async (v) => await view_pack(v.name));
55
61
  // plugins
56
- const plugins = await asyncMap(await plugin_1.default.find({}, { orderBy: "id" }), async (v) => await plugin_pack(v.name));
62
+ const plugins = await asyncMap(await Plugin.find({}, { orderBy: "id" }), async (v) => await plugin_pack(v.name));
57
63
  // pages
58
- const pages = await asyncMap(await page_1.default.find({}), async (v) => await page_pack(v.name));
64
+ const pages = await asyncMap(await Page.find({}), async (v) => await page_pack(v.name));
59
65
  // page groups
60
- const page_groups = await asyncMap(await page_group_1.default.find({}), async (v) => await page_group_pack(v.name));
66
+ const page_groups = await asyncMap(await PageGroup.find({}), async (v) => await page_group_pack(v.name));
61
67
  // triggers
62
- const triggers = await asyncMap(trigger_1.default.find({}), trigger_pack);
68
+ const triggers = await asyncMap(Trigger.find({}), trigger_pack);
63
69
  // roles
64
- const roles = await role_1.default.find({});
70
+ const roles = await Role.find({});
65
71
  // library
66
- const library = (await library_1.default.find({})).map((l) => l.toJson);
72
+ const library = (await Library.find({})).map((l) => l.toJson);
67
73
  // tags
68
- const tags = await asyncMap(await tag_1.default.find({}), async (v) => await tag_pack(v.name));
74
+ const tags = await asyncMap(await Tag.find({}), async (v) => await tag_pack(v.name));
69
75
  // models
70
- const models = (await model_1.default.find({})).map((m) => m.toJson);
76
+ const models = (await Model.find({})).map((m) => m.toJson);
71
77
  // model instances
72
78
  const model_instances = forSnapshot
73
79
  ? []
74
- : await asyncMap(await model_instance_1.default.find({}), async (modelinst) => {
75
- const model = await model_1.default.findOne({ id: modelinst.model_id });
80
+ : await asyncMap(await ModelInstance.find({}), async (modelinst) => {
81
+ const model = await Model.findOne({ id: modelinst.model_id });
76
82
  if (!model)
77
83
  throw new Error(`Model of instance '${modelinst.name}' not found`);
78
- const table = await table_1.default.findOne({ id: model.table_id });
84
+ const table = await Table.findOne({ id: model.table_id });
79
85
  if (!table)
80
86
  throw new Error(`Table of model '${model.name}' not found`);
81
87
  return await model_instance_pack(modelinst.name, model.name, table.name);
82
88
  });
83
89
  // optional event log
84
90
  const event_logs = withEventLog
85
- ? await asyncMap(await eventlog_1.default.find({}), async (e) => await event_log_pack(e))
91
+ ? await asyncMap(await EventLog.find({}), async (e) => await event_log_pack(e))
86
92
  : [];
87
93
  const function_code_pages = state.getConfigCopy("function_code_pages", {});
88
94
  const function_code_pages_tags = state.getConfigCopy("function_code_pages_tags", {});
@@ -110,7 +116,7 @@ const create_pack_json = async (withEventLog = false, forSnapshot = false) => {
110
116
  code_pages,
111
117
  };
112
118
  if (forSnapshot) {
113
- const cfgs = await index_1.default.select("_sc_config");
119
+ const cfgs = await db.select("_sc_config");
114
120
  const config = {};
115
121
  for (const cfg of cfgs) {
116
122
  //exclude base url, multitenancy, ssl/lets encrypt,
@@ -118,82 +124,91 @@ const create_pack_json = async (withEventLog = false, forSnapshot = false) => {
118
124
  continue;
119
125
  if (state.isFixedConfig(cfg.key))
120
126
  continue;
121
- config[cfg.key] = (index_1.default.isSQLite ? JSON.parse(cfg.value) : cfg.value)?.v;
127
+ config[cfg.key] = (db.json_read_returns_string ? JSON.parse(cfg.value) : cfg.value)?.v;
122
128
  }
123
129
  pack.config = config;
124
130
  }
125
131
  return pack;
126
132
  };
127
133
  /**
128
- * @param dirpath
134
+ * @param zip
129
135
  */
130
- const create_pack = async (dirpath) => {
136
+ const add_pack = async (zip) => {
131
137
  const pack = await create_pack_json(getState().getConfig("backup_with_event_log", false));
132
- await (0, promises_1.writeFile)((0, path_1.join)(dirpath, "pack.json"), JSON.stringify(pack));
138
+ await zip.addBuffer("pack.json", Buffer.from(JSON.stringify(pack)));
133
139
  };
134
140
  /**
135
- *
136
141
  * @param rows
137
- * @param fnm
142
+ * @returns the rows as CSV, or null if there are no rows
138
143
  */
139
- const create_csv_from_rows = async (rows, fnm) => {
144
+ const csv_from_rows = (rows) => {
140
145
  if (rows.length === 0)
141
- return;
142
- const s = (0, sync_1.stringify)(rows, {
146
+ return null;
147
+ return stringify(rows, {
143
148
  header: true,
144
149
  cast: {
145
150
  date: (value) => value.toISOString(),
146
151
  },
147
152
  });
148
- await (0, promises_1.writeFile)(fnm, s);
149
153
  };
150
- const sanitiseTableName = (nm) => nm.replaceAll("/", "");
151
154
  /**
152
- * @function
153
- * @param {Table} table
154
- * @param {string} dirpath
155
- * @returns {Promise<void>}
155
+ *
156
+ * @param rows
157
+ * @param fnm
156
158
  */
157
- const create_table_json = async (table, dirpath) => {
158
- await table.dump_to_json((0, path_1.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);
159
164
  };
165
+ const sanitiseTableName = (nm) => nm.replaceAll("/", "");
160
166
  /**
167
+ * Add the JSON dump of every non-external table (and its history, if the
168
+ * table is versioned) straight into the archive.
161
169
  * @function
162
- * @param {string} root_dirpath
170
+ * @param {ZipBuilder} zip
163
171
  * @return {Promise<void>}
164
172
  */
165
- const create_table_jsons = async (root_dirpath) => {
166
- const dirpath = (0, path_1.join)(root_dirpath, "tables");
167
- await (0, promises_1.mkdir)(dirpath, { recursive: true });
168
- const tables = await table_1.default.find({});
173
+ const add_table_jsons = async (zip) => {
174
+ const tables = await Table.find({});
169
175
  const backup_history = getState().getConfig("backup_history", true);
170
176
  for (const t of tables) {
171
177
  if (!t.external && !t.provider_name) {
172
- 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());
173
181
  if (t.versioned && backup_history) {
174
- await t.dump_history_to_json((0, path_1.join)(dirpath, sanitiseTableName(t.name) + "__history.json"));
182
+ await zip.addStream(`tables/${sanitiseTableName(t.name)}__history.json`, t.dump_history_to_json_stream());
175
183
  }
176
184
  }
177
185
  }
178
186
  };
179
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.
180
190
  * @function
181
- * @param {string} root_dirpath
191
+ * @param {ZipBuilder} zip
182
192
  * @returns {Promise<void>}
183
193
  */
184
- const backup_files = async (root_dirpath) => {
194
+ const add_files = async (zip) => {
185
195
  const backup_file_prefix = getState().getConfig("backup_file_prefix");
186
- const dirpath = (0, path_1.join)(root_dirpath, "files");
187
- await (0, promises_1.mkdir)(dirpath);
196
+ const excludeGlobs = parseGlobList(getState().getConfig("backup_exclude_file_globs", ""));
188
197
  const allFiles = [];
189
198
  const iterFolder = async (folder) => {
190
- const files = await file_1.default.find(folder ? { folder } : {});
199
+ const files = await File.find(folder ? { folder } : {});
191
200
  for (const f of files) {
192
- const base = (0, path_1.basename)(f.location);
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
+ }
193
208
  if (f.isDirectory && (await f.is_symlink()))
194
209
  continue;
195
210
  else if (f.isDirectory && !(await f.is_symlink())) {
196
- await (0, promises_1.mkdir)((0, path_1.join)(dirpath, f.path_to_serve));
211
+ await zip.addDirectory(`files/${f.path_to_serve}`);
197
212
  await iterFolder(f.path_to_serve);
198
213
  }
199
214
  else {
@@ -202,49 +217,48 @@ const backup_files = async (root_dirpath) => {
202
217
  f.mime_sub === "zip" &&
203
218
  !f.user_id)
204
219
  continue;
205
- await (0, promises_1.copyFile)(f.location, (0, path_1.join)(dirpath, folder || "", base));
220
+ await zip.addLocalFile(`files/${folder ? `${folder}/` : ""}${base}`, f.location);
206
221
  }
207
- f.location = path_2.default.join(folder || "", base);
222
+ f.location = path.join(folder || "", base);
208
223
  allFiles.push(f);
209
224
  }
210
225
  };
211
226
  await iterFolder();
212
- await create_csv_from_rows(allFiles, (0, path_1.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));
213
230
  };
214
- const backup_migrations = async (root_dirpath) => {
215
- const migrations = await (0, migrate_1.getMigrationsInDB)();
216
- await (0, promises_1.writeFile)((0, path_1.join)(root_dirpath, "migrations.json"), JSON.stringify(migrations));
231
+ const add_migrations = async (zip) => {
232
+ const migrations = await getMigrationsInDB();
233
+ await zip.addBuffer("migrations.json", Buffer.from(JSON.stringify(migrations)));
217
234
  };
218
- const backup_metadata = async (root_dirpath) => {
219
- const metadata = await metadata_1.default.find({});
220
- await (0, promises_1.writeFile)((0, path_1.join)(root_dirpath, "metadata.json"), JSON.stringify(metadata));
235
+ const add_metadata = async (zip) => {
236
+ const metadata = await MetaData.find({});
237
+ await zip.addBuffer("metadata.json", Buffer.from(JSON.stringify(metadata)));
221
238
  };
222
239
  /**
223
240
  * @function
224
- * @param {string} root_dirpath
241
+ * @param {ZipBuilder} zip
225
242
  * @returns {Promise<void>}
226
243
  */
227
- const backup_config = async (root_dirpath) => {
228
- const dirpath = (0, path_1.join)(root_dirpath, "config");
229
- await (0, promises_1.mkdir)(dirpath);
230
- const cfgs = await index_1.default.select("_sc_config");
244
+ const add_config = async (zip) => {
245
+ const cfgs = await db.select("_sc_config");
231
246
  const state = getState();
232
247
  for (const cfg of cfgs) {
233
248
  if (!state.isFixedConfig(cfg.key))
234
- await (0, promises_1.writeFile)((0, path_1.join)(dirpath, cfg.key), JSON.stringify(index_1.default.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)));
235
250
  }
236
251
  };
237
- const backup_info_file = async (root_dirpath) => {
238
- const state = getState();
239
- const migrations_run = await (0, migrate_1.getMigrationsInDB)();
240
- const dbversion = await index_1.default.getVersion(true);
241
- const saltcorn_version = index_1.default.connectObj.sc_version;
242
- await (0, promises_1.writeFile)((0, path_1.join)(root_dirpath, "backup-info.json"), JSON.stringify({
252
+ const add_info_file = async (zip) => {
253
+ const migrations_run = await getMigrationsInDB();
254
+ const dbversion = await db.getVersion(true);
255
+ const saltcorn_version = db.connectObj.sc_version;
256
+ await zip.addBuffer("backup-info.json", Buffer.from(JSON.stringify({
243
257
  saltcorn_version,
244
258
  migrations_run,
245
259
  backup_date: new Date().toISOString(),
246
260
  node_version: process.version,
247
- database_type: index_1.default.isSQLite ? "SQLite" : "PostgreSQL",
261
+ database_type: db.sql_backend_display_name,
248
262
  database_version: dbversion,
249
263
  os: {
250
264
  platform: os.platform(),
@@ -254,131 +268,118 @@ const backup_info_file = async (root_dirpath) => {
254
268
  type: os.type(),
255
269
  release: os.release(),
256
270
  },
257
- }, null, 2));
258
- };
259
- const zipFolder = async (folder, zipFileName) => {
260
- const backup_with_system_zip = executableIsAvailable("zip");
261
- const backup_password = getState().getConfig("backup_password", "");
262
- if (backup_with_system_zip) {
263
- return await new Promise((resolve, reject) => {
264
- const absZipPath = path_2.default.join(process.cwd(), zipFileName);
265
- const args = [
266
- "-5",
267
- "-rq",
268
- ...(backup_password ? [`-P`, backup_password] : []),
269
- absZipPath,
270
- ".",
271
- ];
272
- const subprocess = spawn("zip", args, { cwd: folder });
273
- subprocess.stdout.on("data", (data) => {
274
- getState().log(6, data.toString());
275
- });
276
- subprocess.stderr.on("data", (data) => {
277
- getState().log(1, data.toString());
278
- });
279
- subprocess.on("close", (exitCode) => {
280
- if (exitCode !== 0)
281
- reject(new Error("zip failed"));
282
- else
283
- resolve(undefined);
284
- });
285
- });
286
- }
287
- else {
288
- const zip = new adm_zip_1.default();
289
- zip.addLocalFolder(folder);
290
- zip.writeZip(zipFileName);
291
- }
271
+ }, null, 2)));
292
272
  };
293
273
  /**
294
- * Create backup
295
- * @param fnm
274
+ * The name a backup taken now gets, e.g. sc-backup-Saltcorn-2026-08-14-09-15.zip
296
275
  */
297
- const create_backup = async (fnm) => {
298
- const tmpDir = await (0, tmp_promise_1.dir)({ unsafeCleanup: true });
299
- await create_pack(tmpDir.path);
300
- await create_table_jsons(tmpDir.path);
301
- await backup_files(tmpDir.path);
302
- await backup_config(tmpDir.path);
303
- await backup_migrations(tmpDir.path);
304
- await backup_info_file(tmpDir.path);
305
- await backup_metadata(tmpDir.path);
306
- const day = (0, dateformat_1.default)(new Date(), "yyyy-mm-dd-HH-MM");
307
- const ten = index_1.default.getTenantSchema();
308
- const tens = ten === index_1.default.connectObj.default_schema
276
+ const backup_file_name = () => {
277
+ const day = dateFormat(new Date(), "yyyy-mm-dd-HH-MM");
278
+ const ten = db.getTenantSchema();
279
+ const tens = ten === db.connectObj.default_schema
309
280
  ? getState().getConfig("site_name", "Saltcorn")
310
281
  : ten;
311
282
  const backup_file_prefix = getState().getConfig("backup_file_prefix");
312
- const zipFileName = fnm || `${backup_file_prefix}${tens}-${day}.zip`;
313
- await zipFolder(tmpDir.path, zipFileName);
314
- await tmpDir.cleanup();
315
- return zipFileName;
283
+ return `${backup_file_prefix}${tens}-${day}.zip`;
316
284
  };
317
- // https://stackoverflow.com/a/74743490/19839414
318
- function executableIsAvailable(name) {
319
- 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());
320
308
  try {
321
- shell(`which ${name}`);
322
- return true;
309
+ await fill_backup(zip);
323
310
  }
324
- catch (error) {
325
- return false;
311
+ catch (e) {
312
+ await zip.discard(e);
313
+ throw e;
326
314
  }
327
- }
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
+ };
328
358
  /**
359
+ * Extract a zip file into a directory
329
360
  * @function
330
361
  * @param {string} fnm
331
362
  * @param {string} dir
332
363
  * @returns {Promise<void>}
333
364
  */
334
365
  const extract = async (fnm, dir, password) => {
335
- const state = getState();
336
- const backup_with_system_zip = executableIsAvailable("unzip");
337
- if (backup_with_system_zip) {
338
- return await new Promise((resolve, reject) => {
339
- const args = [
340
- ...(password ? [`-P${password}`] : []),
341
- file_1.default.normalise(fnm),
342
- "-d",
343
- dir,
344
- ];
345
- const subprocess = spawn("unzip", args);
346
- subprocess.stdout.on("data", (data) => {
347
- state.log(6, data.toString());
348
- });
349
- subprocess.stderr.on("data", (data) => {
350
- const output = data.toString();
351
- state.log(1, output);
352
- if (output.includes("password") || output.includes("encrypted")) {
353
- reject({ requiresPassword: true });
354
- }
355
- });
356
- subprocess.on("close", (exitCode) => {
357
- if (exitCode !== 0)
358
- reject(new Error("unzip failed"));
359
- else
360
- resolve();
361
- });
362
- });
363
- }
364
- else {
365
- const zip = new adm_zip_1.default(fnm);
366
- try {
367
- if (password) {
368
- zip.extractAllTo(dir, true, false, password);
369
- }
370
- else {
371
- zip.extractAllTo(dir, true, false);
372
- }
373
- }
374
- catch (error) {
375
- if (error.message.includes("password") ||
376
- error.message.includes("encrypted")) {
377
- error.requiresPassword = true;
378
- }
379
- throw error;
380
- }
381
- }
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
+ };
382
383
  };
383
384
  /**
384
385
  * @function
@@ -386,39 +387,39 @@ const extract = async (fnm, dir, password) => {
386
387
  * @returns {Promise<object>}
387
388
  */
388
389
  const restore_files = async (dirpath) => {
389
- const fnm = (0, path_1.join)(dirpath, "files.csv");
390
+ const fnm = join(dirpath, "files.csv");
390
391
  const file_users = {};
391
392
  const newLocations = {};
392
393
  const state = getState();
393
- if ((0, fs_1.existsSync)(fnm)) {
394
- const file_rows = await (0, csvtojson_1.default)().fromFile(fnm);
394
+ if (existsSync(fnm)) {
395
+ const file_rows = await csvtojson().fromFile(fnm);
395
396
  for (const file of file_rows) {
396
397
  if (file.isDirectory)
397
- await (0, promises_1.mkdir)(file_1.default.get_new_path(file.location), { recursive: true });
398
+ await mkdir(File.get_new_path(file.location), { recursive: true });
398
399
  }
399
400
  if (!isTest())
400
401
  state.log(2, `Restoring ${file_rows.length} files...`);
401
402
  for (const file of file_rows) {
402
403
  try {
403
- const newPath = file_1.default.get_new_path(file.id ? file.filename : file.location);
404
+ const newPath = File.get_new_path(file.id ? file.filename : file.location);
404
405
  //copy file
405
406
  if (!file.isDirectory)
406
- await (0, promises_1.copyFile)((0, path_1.join)(dirpath, "files", file.location), newPath);
407
+ await copyFile(join(dirpath, "files", file.location), newPath);
407
408
  file_users[file.location] = file.user_id;
408
409
  //set location
409
410
  if (file.id)
410
411
  newLocations[file.id] = file.id ? file.filename : file.location;
411
412
  file.location = newPath;
412
413
  //insert in db
413
- await file_1.default.create(file);
414
+ await File.create(file);
414
415
  //const id = await db.insert("_sc_files", file_row);}
415
416
  }
416
417
  catch (e) {
417
418
  state.log(1, `Error restoring file ${JSON.stringify(file)}: ${e.message}`);
418
419
  }
419
420
  }
420
- if (index_1.default.reset_sequence)
421
- await index_1.default.reset_sequence("_sc_files");
421
+ if (db.reset_sequence)
422
+ await db.reset_sequence("_sc_files");
422
423
  }
423
424
  return { file_users, newLocations };
424
425
  };
@@ -427,10 +428,10 @@ const restore_files = async (dirpath) => {
427
428
  * @param newLocations
428
429
  */
429
430
  const correct_fileid_references_to_location = async (newLocations) => {
430
- const fileFields = await field_1.default.find({ type: "File" });
431
+ const fileFields = await Field.find({ type: "File" });
431
432
  getState().log(2, `Correcting file id references to locations`);
432
433
  for (const field of fileFields) {
433
- const table = table_1.default.findOne({ id: field.table_id });
434
+ const table = Table.findOne({ id: field.table_id });
434
435
  const rows = await table.getRows({});
435
436
  for (const row of rows) {
436
437
  if (row[field.name] && newLocations[row[field.name]]) {
@@ -449,7 +450,7 @@ const restore_file_users = async (file_users) => {
449
450
  getState().log(2, `Restoring file users`);
450
451
  for (const [id, user_id] of Object.entries(file_users)) {
451
452
  if (user_id) {
452
- const file = await file_1.default.findOne(id);
453
+ const file = await File.findOne(id);
453
454
  if (file)
454
455
  await file.set_user(user_id);
455
456
  //await db.update("_sc_files", { user_id }, id);
@@ -462,36 +463,37 @@ const restore_file_users = async (file_users) => {
462
463
  * @param {boolean} [restore_first_user]
463
464
  * @returns {Promise<string|undefined>}
464
465
  */
465
- const restore_tables = async (dirpath, restore_first_user) => {
466
+ const restore_tables = async (dirpath, restore_first_user, onLog) => {
466
467
  let err;
467
- const tables = await table_1.default.find();
468
+ const tables = await Table.find();
468
469
  const restore_history = getState().getConfig("restore_history", true);
469
470
  for (const table of tables) {
470
471
  if (!isTest())
471
472
  getState().log(2, `restoring table ${table.name}`);
473
+ onLog?.(`Restoring table ${table.name}`);
472
474
  const fnm_csv = table.name === "users"
473
- ? (0, path_1.join)(dirpath, "users.csv")
474
- : (0, path_1.join)(dirpath, "tables", sanitiseTableName(table.name) + ".csv");
475
- const fnm_json = (0, path_1.join)(dirpath, "tables", sanitiseTableName(table.name) + ".json");
476
- if ((0, fs_1.existsSync)(fnm_json)) {
475
+ ? join(dirpath, "users.csv")
476
+ : join(dirpath, "tables", sanitiseTableName(table.name) + ".csv");
477
+ const fnm_json = join(dirpath, "tables", sanitiseTableName(table.name) + ".json");
478
+ if (existsSync(fnm_json)) {
477
479
  const res = await table.import_json_file(fnm_json, table.name === "users" && !restore_first_user);
478
- if ((0, common_types_1.instanceOfErrorMsg)(res)) {
480
+ if (instanceOfErrorMsg(res)) {
479
481
  console.error(err);
480
482
  err = (err || "") + res.error;
481
483
  }
482
484
  }
483
- else if ((0, fs_1.existsSync)(fnm_csv)) {
485
+ else if (existsSync(fnm_csv)) {
484
486
  const res = await table.import_csv_file(fnm_csv, {
485
487
  skip_first_data_row: table.name === "users" && !restore_first_user,
486
488
  });
487
- if ((0, common_types_1.instanceOfErrorMsg)(res)) {
489
+ if (instanceOfErrorMsg(res)) {
488
490
  console.error(res);
489
491
  err = (err || "") + res.error;
490
492
  }
491
493
  }
492
494
  if (table.versioned && restore_history) {
493
- const fnm_hist_json = (0, path_1.join)(dirpath, "tables", sanitiseTableName(table.name) + "__history.json");
494
- if ((0, fs_1.existsSync)(fnm_hist_json)) {
495
+ const fnm_hist_json = join(dirpath, "tables", sanitiseTableName(table.name) + "__history.json");
496
+ if (existsSync(fnm_hist_json)) {
495
497
  if (!isTest())
496
498
  getState().log(2, `restoring table history ${table.name}`);
497
499
  await table.import_json_history_file(fnm_hist_json);
@@ -515,22 +517,22 @@ const restore_tables = async (dirpath, restore_first_user) => {
515
517
  * @returns {Promise<void>}
516
518
  */
517
519
  const restore_config = async (dirpath) => {
518
- const cfgs = (0, fs_1.readdirSync)((0, path_1.join)(dirpath, "config"));
520
+ const cfgs = readdirSync(join(dirpath, "config"));
519
521
  const state = getState();
520
522
  for (const cfg of cfgs) {
521
- const s = await (0, promises_1.readFile)((0, path_1.join)(dirpath, "config", cfg));
523
+ const s = await readFile(join(dirpath, "config", cfg));
522
524
  await state.setConfig(cfg, JSON.parse(s.toString()).v);
523
525
  }
524
526
  };
525
527
  const restore_metadata = async (dirpath) => {
526
- const fnm = (0, path_1.join)(dirpath, "metadata.json");
527
- if (!(0, fs_1.existsSync)(fnm))
528
+ const fnm = join(dirpath, "metadata.json");
529
+ if (!existsSync(fnm))
528
530
  return;
529
531
  if (!isTest())
530
532
  getState().log(2, `Restoring metadata`);
531
- const mds = JSON.parse((await (0, promises_1.readFile)(fnm)).toString());
533
+ const mds = JSON.parse((await readFile(fnm)).toString());
532
534
  for (const md of mds) {
533
- await metadata_1.default.create(md);
535
+ await MetaData.create(md);
534
536
  }
535
537
  };
536
538
  /**
@@ -538,145 +540,174 @@ const restore_metadata = async (dirpath) => {
538
540
  * @param fnm
539
541
  * @param loadAndSaveNewPlugin
540
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
541
547
  */
542
- const restore = async (fnm, loadAndSaveNewPlugin, restore_first_user, password) => {
548
+ const restore = async (fnm, loadAndSaveNewPlugin, restore_first_user, password, onLog) => {
543
549
  const state = getState();
544
- state.log(2, `Starting restore to tenant ${index_1.default.getTenantSchema()}`);
545
- const tmpDir = await (0, tmp_promise_1.dir)({ unsafeCleanup: true });
546
- await extract(fnm, tmpDir.path, password);
547
- state.log(2, `Unzip done`);
548
- let basePath = tmpDir.path;
549
- // safari re-compressed. Safari unpacks zip files on download. If the user
550
- // chooses compress in finder, the backup dir is nested inside the zip file
551
- if (!(0, fs_1.existsSync)((0, path_1.join)(basePath, "pack.json"))) {
552
- const files = await (0, promises_1.readdir)(basePath);
553
- let found = false;
554
- for (const file of files) {
555
- if ((0, fs_1.existsSync)((0, path_1.join)(basePath, file, "pack.json"))) {
556
- basePath = (0, path_1.join)(basePath, file);
557
- found = true;
558
- 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
+ }
559
572
  }
573
+ if (!found)
574
+ return "Not a valid backup file";
560
575
  }
561
- if (!found)
562
- return "Not a valid backup file";
563
- }
564
- let err;
565
- if ((0, fs_1.existsSync)((0, path_1.join)(basePath, "backup-info.json"))) {
566
- const info = JSON.parse((await (0, promises_1.readFile)((0, path_1.join)(basePath, "backup-info.json"))).toString());
567
- const saltcorn_version = index_1.default.connectObj.sc_version;
568
- if (info.saltcorn_version &&
569
- semver.gt(info.saltcorn_version, saltcorn_version)) {
570
- 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
+ }
571
584
  }
572
- }
573
- //install pack
574
- if (!isTest())
575
- state.log(2, `Reading pack`);
576
- const pack = JSON.parse((await (0, promises_1.readFile)((0, path_1.join)(basePath, "pack.json"))).toString());
577
- const can_restore = await can_install_pack(pack);
578
- if (typeof can_restore !== "boolean" && can_restore.error) {
579
- 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:
580
591
  ${can_restore.error || ""}
581
592
  Delete these entities or restore to a pristine instance.
582
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;
583
615
  }
584
- //config
585
- if (!isTest())
586
- state.log(2, `Restoring config`);
587
- await restore_config(basePath);
588
- if (!isTest())
589
- state.log(2, `Restoring pack`);
590
- await install_pack(pack, undefined, loadAndSaveNewPlugin, true);
591
- // files
592
- if (!isTest())
593
- state.log(2, `Restoring files`);
594
- const { file_users, newLocations } = await restore_files(basePath);
595
- //table csvs
596
- if (!isTest())
597
- state.log(2, `Restoring tables`);
598
- const tabres = await restore_tables(basePath, restore_first_user);
599
- if (tabres)
600
- err = (err || "") + tabres;
601
- if (!isTest())
602
- state.log(2, `Restoring metadata`);
603
- await restore_metadata(basePath);
604
- if (Object.keys(newLocations).length > 0)
605
- await correct_fileid_references_to_location(newLocations);
606
- await restore_file_users(file_users);
607
- await tmpDir.cleanup();
608
- state.log(2, `Completed restore to tenant ${index_1.default.getTenantSchema()}${err ? ` with errors ${err}` : " successfully"}`);
609
- return err;
616
+ finally {
617
+ await tmpDir.cleanup();
618
+ }
619
+ };
620
+ /**
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
+ };
610
655
  };
611
656
  /**
612
- * Delete old backups
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.
613
660
  */
614
661
  const delete_old_backups = async () => {
615
662
  const directory = getState().getConfig("auto_backup_directory");
616
663
  const expire_days = getState().getConfig("auto_backup_expire_days");
617
664
  const backup_file_prefix = getState().getConfig("backup_file_prefix");
618
665
  const destination = getState().getConfig("auto_backup_destination");
619
- 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))
620
669
  return;
670
+ // ---- 1. collect --------------------------------------------------------
671
+ const entries = [];
672
+ let s3;
673
+ let bucket = "";
621
674
  if (destination === "Local directory") {
622
- const files = await (0, promises_1.readdir)(directory);
675
+ const files = await readdir(directory);
623
676
  for (const file of files) {
624
677
  if (!file.startsWith(backup_file_prefix))
625
678
  continue;
626
- const stats = await (0, promises_1.stat)(path_2.default.join(directory, file));
679
+ if (!file.endsWith(".zip"))
680
+ continue;
681
+ const stats = await stat(path.join(directory, file));
627
682
  const fileTime = stats.birthtime?.getTime
628
683
  ? stats.birthtime.getTime()
629
684
  : stats.mtime.getTime();
630
- const ageMs = new Date().getTime() - fileTime;
631
- const ageDays = ageMs / (1000 * 3600 * 24);
632
- if (ageDays > expire_days)
633
- await (0, promises_1.unlink)(path_2.default.join(directory, file));
685
+ entries.push({ key: file, date: new Date(fileTime) });
634
686
  }
635
687
  }
636
688
  else if (destination === "S3") {
637
- const s3EndpointCfg = getState().getConfig("backup_s3_endpoint");
638
- const s3Secure = getState().getConfig("backup_s3_secure", true);
639
- const endpoint = s3EndpointCfg
640
- ? /:\/\//.test(s3EndpointCfg)
641
- ? s3EndpointCfg
642
- : `${s3Secure ? "https" : "http"}://${s3EndpointCfg}`
643
- : undefined;
644
- const s3 = new client_s3_1.S3Client({
645
- credentials: {
646
- accessKeyId: getState().getConfig("backup_s3_access_key"),
647
- secretAccessKey: getState().getConfig("backup_s3_access_secret"),
648
- },
649
- region: getState().getConfig("backup_s3_region"),
650
- ...(endpoint ? { endpoint } : {}),
651
- });
652
- const bucket = getState().getConfig("backup_s3_bucket");
689
+ s3 = buildS3Client(getState());
690
+ bucket = getState().getConfig("backup_s3_bucket");
653
691
  const keyPrefix = (getState().getConfig("backup_s3_path_prefix", "") || "")
654
692
  .toString()
655
693
  .replace(/^\/+|\/+$/g, "");
656
694
  const listPrefix = [keyPrefix, backup_file_prefix]
657
695
  .filter((s) => s && s.length)
658
696
  .join("/");
659
- const listParams = {
660
- Bucket: bucket,
661
- Prefix: listPrefix,
662
- };
663
697
  try {
664
698
  let continuationToken = undefined;
665
699
  do {
666
- const listedObjects = await s3.send(new client_s3_1.ListObjectsV2Command({
667
- ...listParams,
700
+ const listedObjects = await s3.send(new (awsS3().ListObjectsV2Command)({
701
+ Bucket: bucket,
702
+ Prefix: listPrefix,
668
703
  ContinuationToken: continuationToken,
669
704
  }));
670
- if (listedObjects.Contents) {
671
- for (const obj of listedObjects.Contents) {
672
- if (!obj.Key || !obj.LastModified)
673
- continue;
674
- const ageMs = new Date().getTime() - new Date(obj.LastModified).getTime();
675
- const ageDays = ageMs / (1000 * 3600 * 24);
676
- if (ageDays > expire_days) {
677
- await s3.send(new client_s3_1.DeleteObjectCommand({ Bucket: bucket, Key: obj.Key }));
678
- }
679
- }
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) });
680
711
  }
681
712
  continuationToken = listedObjects.IsTruncated
682
713
  ? listedObjects.NextContinuationToken
@@ -684,7 +715,40 @@ const delete_old_backups = async () => {
684
715
  } while (continuationToken);
685
716
  }
686
717
  catch (err) {
687
- 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}`);
688
752
  }
689
753
  }
690
754
  };
@@ -692,23 +756,23 @@ const delete_old_backups = async () => {
692
756
  * Do autobackup now
693
757
  */
694
758
  const auto_backup_now_tenant = async (state) => {
695
- state.log(6, `Creating backup file`);
696
- const fileName = await create_backup();
697
- state.log(6, `Created backup file with name ${fileName}`);
698
759
  const destination = state.getConfig("auto_backup_destination", "Saltcorn files");
699
760
  const directory = state.getConfig("auto_backup_directory", "");
700
761
  if (directory === null)
701
762
  throw new Error("Directory is unspecified");
702
- 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}`);
703
767
  switch (destination) {
704
- case "Saltcorn files":
768
+ case "Saltcorn files": {
705
769
  if (directory.length > 0) {
706
- await file_1.default.new_folder(directory);
770
+ await File.new_folder(directory);
707
771
  }
708
- const newPath = file_1.default.get_new_path((0, path_1.join)(directory, fileName));
709
- const stats = (0, fs_1.statSync)(fileName);
710
- await (0, promises_1.copyFile)(fileName, newPath);
711
- await file_1.default.create({
772
+ const newPath = File.get_new_path(join(directory, fileName));
773
+ await write_backup_to_file(newPath);
774
+ const stats = await stat(newPath);
775
+ await File.create({
712
776
  filename: fileName,
713
777
  location: newPath,
714
778
  uploaded_at: new Date(),
@@ -717,71 +781,73 @@ const auto_backup_now_tenant = async (state) => {
717
781
  mime_sub: "zip",
718
782
  min_role_read: 1,
719
783
  });
720
- await (0, promises_1.unlink)(fileName);
721
784
  break;
722
- case "Local directory":
723
- //const directory = state.getConfig("auto_backup_directory");
785
+ }
786
+ case "Local directory": {
724
787
  if (directory.length > 0) {
725
- await (0, promises_1.mkdir)(directory, { recursive: true });
788
+ await mkdir(directory, { recursive: true });
726
789
  }
727
- await (0, promises_1.rename)(fileName, (0, path_1.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));
728
793
  await delete_old_backups();
729
794
  break;
730
- case "SFTP server":
731
- let sftp = new ssh2_sftp_client_1.default();
732
- await sftp.connect({
733
- host: state.getConfig("auto_backup_server"),
734
- port: state.getConfig("auto_backup_port"),
735
- username: state.getConfig("auto_backup_username"),
736
- password: state.getConfig("auto_backup_password"),
737
- });
738
- let data = (0, fs_1.createReadStream)(fileName);
739
- let remote = (0, path_1.join)(state.getConfig("auto_backup_directory", ""), (0, path_1.basename)(fileName));
740
- const putres = await sftp.put(data, remote);
741
- state.log(6, `SFTP Put response: ${putres}`);
742
- await sftp.end();
795
+ }
796
+ case "SFTP server": {
797
+ const remote = join(state.getConfig("auto_backup_directory", ""), fileName);
743
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
+ };
744
815
  if (retain_dir) {
745
- await (0, promises_1.mkdir)(retain_dir, { recursive: true });
746
- await (0, promises_1.copyFile)(fileName, (0, path_1.join)(retain_dir, fileName));
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
818
+ await mkdir(retain_dir, { recursive: true });
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);
747
826
  }
748
- await (0, promises_1.unlink)(fileName);
749
827
  break;
750
- case "S3":
751
- const bEndpointCfg = state.getConfig("backup_s3_endpoint");
752
- const bSecure = state.getConfig("backup_s3_secure", true);
753
- const bEndpoint = bEndpointCfg
754
- ? /:\/\//.test(bEndpointCfg)
755
- ? bEndpointCfg
756
- : `${bSecure ? "https" : "http"}://${bEndpointCfg}`
757
- : undefined;
758
- const s3 = new client_s3_1.S3({
759
- credentials: {
760
- accessKeyId: state.getConfig("backup_s3_access_key"),
761
- secretAccessKey: state.getConfig("backup_s3_access_secret"),
762
- },
763
- region: state.getConfig("backup_s3_region"),
764
- ...(bEndpoint ? { endpoint: bEndpoint } : {}),
765
- });
828
+ }
829
+ case "S3": {
830
+ const s3 = new (awsS3().S3)(s3ClientConfig(state));
766
831
  const bucket = state.getConfig("backup_s3_bucket");
767
832
  const pathPrefix = (state.getConfig("backup_s3_path_prefix", "") || "")
768
833
  .toString()
769
834
  .replace(/^\/+|\/+$/g, "");
770
- const s3Key = [pathPrefix, (0, path_1.basename)(fileName)]
835
+ const s3Key = [pathPrefix, fileName]
771
836
  .filter((s) => s && s.length)
772
837
  .join("/");
773
- const fileStream = () => (0, fs_1.createReadStream)(fileName);
774
838
  try {
775
- const uploadResult = await new lib_storage_1.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)({
776
842
  client: s3,
777
843
  params: {
778
844
  Bucket: bucket,
779
845
  Key: s3Key,
780
- Body: fileStream(),
846
+ Body: body,
781
847
  ACL: "private",
782
848
  ContentType: "application/zip",
783
849
  },
784
- }).done();
850
+ }).done());
785
851
  if (uploadResult.$metadata.httpStatusCode !== 200) {
786
852
  throw new Error(`S3 Upload failed with status code ${uploadResult.$metadata.httpStatusCode}`);
787
853
  }
@@ -792,19 +858,21 @@ const auto_backup_now_tenant = async (state) => {
792
858
  }
793
859
  await delete_old_backups();
794
860
  break;
861
+ }
795
862
  default:
796
863
  throw new Error("Unknown destination: " + destination);
797
864
  }
865
+ state.log(6, `Backup ${fileName} written to destination ${destination}`);
798
866
  };
799
867
  const auto_backup_now = async () => {
800
- const isRoot = index_1.default.getTenantSchema() === index_1.default.connectObj.default_schema;
868
+ const isRoot = db.getTenantSchema() === db.connectObj.default_schema;
801
869
  const state = getState();
802
- const tenantModule = require("./tenant");
870
+ const tenantModule = (await import("./tenant.js")).default;
803
871
  if (isRoot && state.getConfig("auto_backup_tenants"))
804
872
  await tenantModule.eachTenant(async () => {
805
873
  try {
806
874
  await auto_backup_now_tenant(state);
807
- await metadata_1.default.create({
875
+ await MetaData.create({
808
876
  type: "Backup",
809
877
  name: "Success",
810
878
  body: {},
@@ -813,7 +881,7 @@ const auto_backup_now = async () => {
813
881
  }
814
882
  catch (e) {
815
883
  console.error(e);
816
- await crash_1.default.create(e, {
884
+ await Crash.create(e, {
817
885
  url: `Scheduler auto backup for tenant`,
818
886
  headers: {},
819
887
  });
@@ -823,7 +891,7 @@ const auto_backup_now = async () => {
823
891
  else
824
892
  try {
825
893
  await auto_backup_now_tenant(state);
826
- await metadata_1.default.create({
894
+ await MetaData.create({
827
895
  type: "Backup",
828
896
  name: "Success",
829
897
  body: {},
@@ -832,15 +900,17 @@ const auto_backup_now = async () => {
832
900
  }
833
901
  catch (e) {
834
902
  console.error(e);
835
- await crash_1.default.create(e, {
903
+ await Crash.create(e, {
836
904
  url: `Scheduler auto backup`,
837
905
  headers: {},
838
906
  });
839
907
  throw new Error(e);
840
908
  }
841
909
  };
842
- module.exports = {
910
+ export default {
843
911
  create_backup,
912
+ backup_to_stream,
913
+ backup_file_name,
844
914
  restore,
845
915
  create_csv_from_rows,
846
916
  auto_backup_now,