@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.
- package/cjs/index.cjs +18 -0
- package/cjs/models/backup-retention.cjs +18 -0
- package/cjs/models/backup.cjs +18 -0
- package/cjs/models/config-check.cjs +18 -0
- package/cjs/models/file-globs.cjs +18 -0
- package/cjs/models/pack.cjs +18 -0
- package/cjs/models/snapshot.cjs +18 -0
- package/cjs/models/tenant.cjs +18 -0
- package/cjs/models/zip-extract.cjs +18 -0
- package/cjs/models/zip-format.cjs +18 -0
- package/cjs/models/zip-worker.cjs +18 -0
- package/cjs/models/zip-writer.cjs +18 -0
- package/cjs/models/zip.cjs +18 -0
- package/dist/index.d.ts +6 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -18
- package/dist/index.js.map +1 -1
- package/dist/models/backup-retention.d.ts +57 -0
- package/dist/models/backup-retention.d.ts.map +1 -0
- package/dist/models/backup-retention.js +108 -0
- package/dist/models/backup-retention.js.map +1 -0
- package/dist/models/backup.d.ts +7 -3
- package/dist/models/backup.d.ts.map +1 -1
- package/dist/models/backup.js +479 -409
- package/dist/models/backup.js.map +1 -1
- package/dist/models/config-check.d.ts +2 -1
- package/dist/models/config-check.d.ts.map +1 -1
- package/dist/models/config-check.js +16 -23
- package/dist/models/config-check.js.map +1 -1
- package/dist/models/file-globs.d.ts +34 -0
- package/dist/models/file-globs.d.ts.map +1 -0
- package/dist/models/file-globs.js +47 -0
- package/dist/models/file-globs.js.map +1 -0
- package/dist/models/pack.d.ts +3 -2
- package/dist/models/pack.d.ts.map +1 -1
- package/dist/models/pack.js +147 -151
- package/dist/models/pack.js.map +1 -1
- package/dist/models/snapshot.d.ts +1 -1
- package/dist/models/snapshot.d.ts.map +1 -1
- package/dist/models/snapshot.js +26 -29
- package/dist/models/snapshot.js.map +1 -1
- package/dist/models/tenant.d.ts +1 -1
- package/dist/models/tenant.d.ts.map +1 -1
- package/dist/models/tenant.js +45 -48
- package/dist/models/tenant.js.map +1 -1
- package/dist/models/zip-extract.d.ts +14 -0
- package/dist/models/zip-extract.d.ts.map +1 -0
- package/dist/models/zip-extract.js +110 -0
- package/dist/models/zip-extract.js.map +1 -0
- package/dist/models/zip-format.d.ts +57 -0
- package/dist/models/zip-format.d.ts.map +1 -0
- package/dist/models/zip-format.js +106 -0
- package/dist/models/zip-format.js.map +1 -0
- package/dist/models/zip-worker.d.ts +2 -0
- package/dist/models/zip-worker.d.ts.map +1 -0
- package/dist/models/zip-worker.js +177 -0
- package/dist/models/zip-worker.js.map +1 -0
- package/dist/models/zip-writer.d.ts +75 -0
- package/dist/models/zip-writer.d.ts.map +1 -0
- package/dist/models/zip-writer.js +321 -0
- package/dist/models/zip-writer.js.map +1 -0
- package/dist/models/zip.d.ts +101 -0
- package/dist/models/zip.d.ts.map +1 -0
- package/dist/models/zip.js +211 -0
- package/dist/models/zip.js.map +1 -0
- package/dist/tests/backup-retention.test.d.ts +2 -0
- package/dist/tests/backup-retention.test.d.ts.map +1 -0
- package/dist/tests/backup-retention.test.js +134 -0
- package/dist/tests/backup-retention.test.js.map +1 -0
- package/dist/tests/backup.test.d.ts +2 -0
- package/dist/tests/backup.test.d.ts.map +1 -0
- package/dist/tests/backup.test.js +385 -0
- package/dist/tests/backup.test.js.map +1 -0
- package/dist/tests/cfgcheck.test.d.ts +2 -0
- package/dist/tests/cfgcheck.test.d.ts.map +1 -0
- package/dist/tests/cfgcheck.test.js +32 -0
- package/dist/tests/cfgcheck.test.js.map +1 -0
- package/dist/tests/file-globs.test.d.ts +2 -0
- package/dist/tests/file-globs.test.d.ts.map +1 -0
- package/dist/tests/file-globs.test.js +66 -0
- package/dist/tests/file-globs.test.js.map +1 -0
- package/dist/tests/pack.test.d.ts +2 -0
- package/dist/tests/pack.test.d.ts.map +1 -0
- package/dist/tests/pack.test.js +780 -0
- package/dist/tests/pack.test.js.map +1 -0
- package/dist/tests/random.test.d.ts +2 -0
- package/dist/tests/random.test.d.ts.map +1 -0
- package/dist/tests/random.test.js +150 -0
- package/dist/tests/random.test.js.map +1 -0
- package/dist/tests/tenant.test.d.ts +2 -0
- package/dist/tests/tenant.test.d.ts.map +1 -0
- package/dist/tests/tenant.test.js +106 -0
- package/dist/tests/tenant.test.js.map +1 -0
- package/dist/tests/zip.test.d.ts +2 -0
- package/dist/tests/zip.test.d.ts.map +1 -0
- package/dist/tests/zip.test.js +319 -0
- package/dist/tests/zip.test.js.map +1 -0
- package/gen-cjs-shims.cjs +71 -0
- package/package.json +39 -29
package/dist/models/backup.js
CHANGED
|
@@ -1,88 +1,94 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
68
|
+
const triggers = await asyncMap(Trigger.find({}), trigger_pack);
|
|
63
69
|
// roles
|
|
64
|
-
const roles = await
|
|
70
|
+
const roles = await Role.find({});
|
|
65
71
|
// library
|
|
66
|
-
const library = (await
|
|
72
|
+
const library = (await Library.find({})).map((l) => l.toJson);
|
|
67
73
|
// tags
|
|
68
|
-
const tags = await asyncMap(await
|
|
74
|
+
const tags = await asyncMap(await Tag.find({}), async (v) => await tag_pack(v.name));
|
|
69
75
|
// models
|
|
70
|
-
const models = (await
|
|
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
|
|
75
|
-
const model = await
|
|
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
|
|
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
|
|
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
|
|
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] = (
|
|
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
|
|
134
|
+
* @param zip
|
|
129
135
|
*/
|
|
130
|
-
const
|
|
136
|
+
const add_pack = async (zip) => {
|
|
131
137
|
const pack = await create_pack_json(getState().getConfig("backup_with_event_log", false));
|
|
132
|
-
await
|
|
138
|
+
await zip.addBuffer("pack.json", Buffer.from(JSON.stringify(pack)));
|
|
133
139
|
};
|
|
134
140
|
/**
|
|
135
|
-
*
|
|
136
141
|
* @param rows
|
|
137
|
-
* @
|
|
142
|
+
* @returns the rows as CSV, or null if there are no rows
|
|
138
143
|
*/
|
|
139
|
-
const
|
|
144
|
+
const csv_from_rows = (rows) => {
|
|
140
145
|
if (rows.length === 0)
|
|
141
|
-
return;
|
|
142
|
-
|
|
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
|
-
*
|
|
153
|
-
* @param
|
|
154
|
-
* @param
|
|
155
|
-
* @returns {Promise<void>}
|
|
155
|
+
*
|
|
156
|
+
* @param rows
|
|
157
|
+
* @param fnm
|
|
156
158
|
*/
|
|
157
|
-
const
|
|
158
|
-
|
|
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 {
|
|
170
|
+
* @param {ZipBuilder} zip
|
|
163
171
|
* @return {Promise<void>}
|
|
164
172
|
*/
|
|
165
|
-
const
|
|
166
|
-
const
|
|
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
|
-
|
|
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
|
|
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 {
|
|
191
|
+
* @param {ZipBuilder} zip
|
|
182
192
|
* @returns {Promise<void>}
|
|
183
193
|
*/
|
|
184
|
-
const
|
|
194
|
+
const add_files = async (zip) => {
|
|
185
195
|
const backup_file_prefix = getState().getConfig("backup_file_prefix");
|
|
186
|
-
const
|
|
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
|
|
199
|
+
const files = await File.find(folder ? { folder } : {});
|
|
191
200
|
for (const f of files) {
|
|
192
|
-
const base =
|
|
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
|
|
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
|
|
220
|
+
await zip.addLocalFile(`files/${folder ? `${folder}/` : ""}${base}`, f.location);
|
|
206
221
|
}
|
|
207
|
-
f.location =
|
|
222
|
+
f.location = path.join(folder || "", base);
|
|
208
223
|
allFiles.push(f);
|
|
209
224
|
}
|
|
210
225
|
};
|
|
211
226
|
await iterFolder();
|
|
212
|
-
|
|
227
|
+
const csv = csv_from_rows(allFiles);
|
|
228
|
+
if (csv !== null)
|
|
229
|
+
await zip.addBuffer("files.csv", Buffer.from(csv));
|
|
213
230
|
};
|
|
214
|
-
const
|
|
215
|
-
const migrations = await
|
|
216
|
-
await
|
|
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
|
|
219
|
-
const metadata = await
|
|
220
|
-
await
|
|
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 {
|
|
241
|
+
* @param {ZipBuilder} zip
|
|
225
242
|
* @returns {Promise<void>}
|
|
226
243
|
*/
|
|
227
|
-
const
|
|
228
|
-
const
|
|
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
|
|
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
|
|
238
|
-
const
|
|
239
|
-
const
|
|
240
|
-
const
|
|
241
|
-
|
|
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:
|
|
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
|
-
*
|
|
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
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
313
|
-
await zipFolder(tmpDir.path, zipFileName);
|
|
314
|
-
await tmpDir.cleanup();
|
|
315
|
-
return zipFileName;
|
|
283
|
+
return `${backup_file_prefix}${tens}-${day}.zip`;
|
|
316
284
|
};
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
322
|
-
return true;
|
|
309
|
+
await fill_backup(zip);
|
|
323
310
|
}
|
|
324
|
-
catch (
|
|
325
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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 =
|
|
390
|
+
const fnm = join(dirpath, "files.csv");
|
|
390
391
|
const file_users = {};
|
|
391
392
|
const newLocations = {};
|
|
392
393
|
const state = getState();
|
|
393
|
-
if (
|
|
394
|
-
const file_rows = await (
|
|
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
|
|
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 =
|
|
404
|
+
const newPath = File.get_new_path(file.id ? file.filename : file.location);
|
|
404
405
|
//copy file
|
|
405
406
|
if (!file.isDirectory)
|
|
406
|
-
await
|
|
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
|
|
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 (
|
|
421
|
-
await
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
?
|
|
474
|
-
:
|
|
475
|
-
const fnm_json =
|
|
476
|
-
if (
|
|
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 (
|
|
480
|
+
if (instanceOfErrorMsg(res)) {
|
|
479
481
|
console.error(err);
|
|
480
482
|
err = (err || "") + res.error;
|
|
481
483
|
}
|
|
482
484
|
}
|
|
483
|
-
else if (
|
|
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 (
|
|
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 =
|
|
494
|
-
if (
|
|
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 =
|
|
520
|
+
const cfgs = readdirSync(join(dirpath, "config"));
|
|
519
521
|
const state = getState();
|
|
520
522
|
for (const cfg of cfgs) {
|
|
521
|
-
const s = await
|
|
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 =
|
|
527
|
-
if (!
|
|
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
|
|
533
|
+
const mds = JSON.parse((await readFile(fnm)).toString());
|
|
532
534
|
for (const md of mds) {
|
|
533
|
-
await
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
const
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
638
|
-
|
|
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
|
|
667
|
-
|
|
700
|
+
const listedObjects = await s3.send(new (awsS3().ListObjectsV2Command)({
|
|
701
|
+
Bucket: bucket,
|
|
702
|
+
Prefix: listPrefix,
|
|
668
703
|
ContinuationToken: continuationToken,
|
|
669
704
|
}));
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
770
|
+
await File.new_folder(directory);
|
|
707
771
|
}
|
|
708
|
-
const newPath =
|
|
709
|
-
|
|
710
|
-
await (
|
|
711
|
-
await
|
|
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
|
-
|
|
723
|
-
|
|
785
|
+
}
|
|
786
|
+
case "Local directory": {
|
|
724
787
|
if (directory.length > 0) {
|
|
725
|
-
await
|
|
788
|
+
await mkdir(directory, { recursive: true });
|
|
726
789
|
}
|
|
727
|
-
|
|
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
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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
|
-
|
|
746
|
-
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
const
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
868
|
+
const isRoot = db.getTenantSchema() === db.connectObj.default_schema;
|
|
801
869
|
const state = getState();
|
|
802
|
-
const tenantModule =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|