@jwilleke/ngdpbase-types 5.20.0 → 5.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/packages/ngdpbase-types/index.d.ts +5 -0
- package/lib/src/mail/MailProvider.d.ts +2 -0
- package/lib/src/managers/AddonsManager.d.ts +3 -8
- package/lib/src/managers/AttachmentManager.d.ts +64 -12
- package/lib/src/managers/AuthManager.d.ts +24 -4
- package/lib/src/managers/BackgroundJobManager.d.ts +71 -1
- package/lib/src/managers/BaseManager.d.ts +20 -0
- package/lib/src/managers/BaseManager.js +9 -0
- package/lib/src/managers/CommentManager.d.ts +10 -1
- package/lib/src/managers/ConfigurationManager.d.ts +16 -1
- package/lib/src/managers/DatabaseManager.d.ts +53 -16
- package/lib/src/managers/DatabaseManager.js +172 -38
- package/lib/src/managers/EmailManager.d.ts +42 -0
- package/lib/src/managers/FootnoteManager.d.ts +10 -1
- package/lib/src/managers/NotificationManager.d.ts +13 -0
- package/lib/src/managers/OrganizationManager.d.ts +40 -6
- package/lib/src/managers/PersonManager.d.ts +106 -9
- package/lib/src/managers/RoleManager.d.ts +19 -1
- package/lib/src/managers/UserManager.d.ts +15 -0
- package/lib/src/providers/BaseAttachmentProvider.d.ts +14 -0
- package/lib/src/providers/BasicAttachmentProvider.d.ts +26 -2
- package/lib/src/providers/SqliteDatabaseProvider.d.ts +12 -0
- package/lib/src/providers/SqliteDatabaseProvider.js +50 -7
- package/lib/src/providers/SqliteJobStateProvider.d.ts +10 -0
- package/lib/src/providers/attachmentFilesBackup.d.ts +52 -0
- package/lib/src/providers/sqliteBackup.d.ts +6 -0
- package/lib/src/providers/sqliteBackup.js +16 -6
- package/lib/src/security/permissions.generated.d.ts +4 -0
- package/lib/src/types/Asset.d.ts +10 -0
- package/lib/src/types/Config.d.ts +11 -0
- package/lib/src/types/Organization.d.ts +7 -0
- package/lib/src/types/Person.d.ts +27 -3
- package/lib/src/types/PersonProvider.d.ts +6 -0
- package/lib/src/types/WikiEngine.d.ts +9 -21
- package/lib/src/utils/addonConfigLayer.d.ts +19 -0
- package/lib/src/utils/addonConfigLayer.js +45 -3
- package/lib/src/utils/addonPageSync.d.ts +24 -0
- package/lib/src/utils/administrators.d.ts +21 -0
- package/lib/src/utils/auditEventNames.generated.d.ts +4 -0
- package/lib/src/utils/auditEventNames.generated.js +4 -0
- package/lib/src/utils/backupCrypto.d.ts +28 -0
- package/lib/src/utils/backupKey.d.ts +23 -0
- package/lib/src/utils/backupKey.js +37 -0
- package/lib/src/utils/configPaths.d.ts +55 -0
- package/lib/src/utils/configPaths.js +133 -0
- package/lib/src/utils/contactAccess.d.ts +58 -0
- package/lib/src/utils/databaseKey.d.ts +7 -0
- package/lib/src/utils/databaseKey.js +7 -0
- package/lib/src/utils/directoryBackup.d.ts +78 -0
- package/lib/src/utils/emailForLog.d.ts +20 -0
- package/lib/src/utils/maintenanceState.d.ts +153 -0
- package/lib/src/utils/pluginFormatters.d.ts +6 -1
- package/lib/src/utils/pluginFormatters.js +5 -2
- package/lib/src/utils/privateStoreTakeout.d.ts +2 -2
- package/lib/src/utils/redactSecrets.js +31 -11
- package/lib/src/utils/sensitiveValues.d.ts +49 -3
- package/lib/src/utils/sensitiveValues.js +139 -11
- package/lib/src/utils/siteRestore.d.ts +110 -0
- package/lib/src/utils/siteRestore.js +309 -0
- package/lib/src/utils/treeArchive.d.ts +44 -0
- package/lib/src/utils/username.d.ts +7 -1
- package/package.json +1 -1
|
@@ -14,9 +14,11 @@
|
|
|
14
14
|
* when `NGDPBASE_DATABASE_KEY` is set (in the environment or the instance
|
|
15
15
|
* `.env`), never from config. Without a key it opens unencrypted and says so.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
* filesystem, when the schema is from a newer build, when a migration
|
|
19
|
-
* or when the integrity check does
|
|
17
|
+
* The database is refused, naming the cause, when the file would sit on a
|
|
18
|
+
* network filesystem, when the schema is from a newer build, when a migration
|
|
19
|
+
* fails, when the key is missing or wrong, or when the integrity check does
|
|
20
|
+
* not pass. The process stays up (#1892): the site starts in maintenance mode
|
|
21
|
+
* and administrators are told the fix (`getUnavailableReason`).
|
|
20
22
|
*
|
|
21
23
|
* An add-on that keeps rows gets a database of its own the same way
|
|
22
24
|
* (`openAddonDatabase`, ngdp-accounting-addons#13): its own file beside the
|
|
@@ -38,33 +40,22 @@ import path from 'node:path';
|
|
|
38
40
|
import BaseManager from './BaseManager.js';
|
|
39
41
|
import SqliteDatabaseProvider, { DEFAULT_BUSY_TIMEOUT_MS } from '../providers/SqliteDatabaseProvider.js';
|
|
40
42
|
import DATABASE_MIGRATIONS from '../providers/databaseMigrations.js';
|
|
41
|
-
import { createHmac } from 'node:crypto';
|
|
42
43
|
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
43
44
|
import { actorOf } from '../context/ActorContext.js';
|
|
44
45
|
import { recordAuditEvent } from '../utils/auditEvents.js';
|
|
45
46
|
import { AUDIT_EVENT } from '../utils/auditEventNames.js';
|
|
46
47
|
import logger from '../utils/logger.js';
|
|
47
48
|
import { ADDON_SLUG, ledgerPrefix } from '../utils/addonDatabaseRules.js';
|
|
48
|
-
import { applyStagedRestore, manifestPath, runOffThread, stagedPath, writeManifest } from '../providers/sqliteBackup.js';
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
*/
|
|
57
|
-
export const BACKUP_KEY_ENV = 'NGDPBASE_BACKUP_KEY';
|
|
49
|
+
import { applyStagedRestore, manifestPath, runOffThread, setAsideStagedRestore, stagedPath, writeManifest } from '../providers/sqliteBackup.js';
|
|
50
|
+
import { gatePart, markPart, recordStagedPart, siteRestoreFile } from '../utils/siteRestore.js';
|
|
51
|
+
import { BACKUP_KEY_ENV, backupKeyFingerprint, currentBackupKey, LEFT_OUT_FOR_NO_KEY } from '../utils/backupKey.js';
|
|
52
|
+
// The environment variable holding the SQLCipher key: one declaration, `src/utils/databaseKey.ts`.
|
|
53
|
+
import { DATABASE_KEY_ENV } from '../utils/databaseKey.js';
|
|
54
|
+
export { DATABASE_KEY_ENV };
|
|
55
|
+
// #1723, #1876: the backup key has one declaration (`src/utils/backupKey.ts`); re-exported for the callers that import it from here.
|
|
56
|
+
export { BACKUP_KEY_ENV, backupKeyFingerprint } from '../utils/backupKey.js';
|
|
58
57
|
/** How long a database backup or restore staging may run on its worker before it is stopped, when not configured. */
|
|
59
58
|
export const DEFAULT_BACKUP_WORKER_TIMEOUT_MS = 3_600_000;
|
|
60
|
-
/**
|
|
61
|
-
* A short fingerprint of a backup key (#1723, D3): the first 16 hex digits of
|
|
62
|
-
* an HMAC of a fixed label under the key. Recorded in each backup so a restore
|
|
63
|
-
* under another key says so plainly; it never reveals the key.
|
|
64
|
-
*/
|
|
65
|
-
export function backupKeyFingerprint(key) {
|
|
66
|
-
return createHmac('sha256', key).update('ngdpbase backup key fingerprint v1').digest('hex').slice(0, 16);
|
|
67
|
-
}
|
|
68
59
|
/** The backup's name for the application database; an add-on's is `addon-<owner>`. */
|
|
69
60
|
const APPLICATION = 'application';
|
|
70
61
|
const addonName = (owner) => `addon-${owner}`;
|
|
@@ -109,8 +100,14 @@ class DatabaseManager extends BaseManager {
|
|
|
109
100
|
addonDatabases = new Map();
|
|
110
101
|
/** What the start did with a staged restore (#1723), reported once the notifications exist. */
|
|
111
102
|
restoreAtStart = null;
|
|
103
|
+
/** The databases a staged restore put back at this start (#1876): kept after the report, for the managers whose data is in them. */
|
|
104
|
+
restoredNow = [];
|
|
105
|
+
/** When the restored backup was taken, and where the replaced application database is kept, when a restore was applied at this start (#1876). */
|
|
106
|
+
replaced = null;
|
|
112
107
|
/** Why this manager is degraded, one entry per cause, so one cause clearing leaves the others. */
|
|
113
108
|
faults = new Map();
|
|
109
|
+
/** Why the configured application database could not be opened safely (#1892); null otherwise. */
|
|
110
|
+
unavailable = null;
|
|
114
111
|
constructor(engine) {
|
|
115
112
|
super(engine);
|
|
116
113
|
}
|
|
@@ -133,15 +130,32 @@ class DatabaseManager extends BaseManager {
|
|
|
133
130
|
this.busyTimeoutMs = busy;
|
|
134
131
|
const file = configManager.getResolvedDataPath('ngdpbase.database.file', './data/ngdpbase.db');
|
|
135
132
|
const key = process.env[DATABASE_KEY_ENV] ?? '';
|
|
133
|
+
// Known even when it does not open (#1892): a staged restore stays visible and cancellable.
|
|
134
|
+
this.databaseFile = file;
|
|
136
135
|
// #1723: a staged restore goes in now, before any database opens.
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
136
|
+
try {
|
|
137
|
+
this.applyRestoreAtStart(file);
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
this.markUnavailable(`A staged database restore could not be put in place: ${err.message}. ` +
|
|
141
|
+
'No database was opened. Some databases may already be restored: each one moved is kept beside it as *.pre-restore, and the rest are still *.staged beside ' +
|
|
142
|
+
`${path.basename(manifestPath(file))}. Stop the server, put the files back as they should be, and restart (#1892)`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
// #1892: a database that cannot be opened safely (no key or the wrong key
|
|
146
|
+
// on an encrypted file, a damaged file, a failed migration, a newer
|
|
147
|
+
// schema) does not stop the process: the site starts in maintenance mode.
|
|
148
|
+
// The provider checks integrity before it writes anything, and a failed
|
|
149
|
+
// migration is rolled back.
|
|
150
|
+
let provider;
|
|
151
|
+
try {
|
|
152
|
+
provider = new SqliteDatabaseProvider(file, key, DATABASE_MIGRATIONS, 'schema_migrations', { busyTimeoutMs: this.busyTimeoutMs });
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
this.markUnavailable(DatabaseManager.openFailure(file, err.message, key));
|
|
156
|
+
return;
|
|
142
157
|
}
|
|
143
158
|
this.provider = provider;
|
|
144
|
-
this.databaseFile = file;
|
|
145
159
|
const { applied, skipped } = provider.migrations;
|
|
146
160
|
logger.info(`[DatabaseManager] Opened ${file} (${key ? 'encrypted' : 'NOT encrypted'}, lock wait ${this.busyTimeoutMs} ms) — migrations applied ${applied.length}, already present ${skipped}`);
|
|
147
161
|
if (!key) {
|
|
@@ -157,6 +171,45 @@ class DatabaseManager extends BaseManager {
|
|
|
157
171
|
isEnabled() {
|
|
158
172
|
return this.provider !== null;
|
|
159
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* Why the configured application database is not open (#1892): it could not
|
|
176
|
+
* be opened safely, and the site is in maintenance mode. Null when it is
|
|
177
|
+
* open, or when none is configured (`ngdpbase.database.provider` `none`).
|
|
178
|
+
* A manager that needs the database asks this and holds its work.
|
|
179
|
+
*/
|
|
180
|
+
getUnavailableReason() {
|
|
181
|
+
return this.unavailable;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* #1892 (operator D3 rule, 2026-10-18): the application database can't be
|
|
185
|
+
* opened safely. The process stays up in maintenance mode through the one
|
|
186
|
+
* door (`engine.maintenance`, #1906): visitors see only the site's maintenance message,
|
|
187
|
+
* administrators can sign in and reach /admin, and `reportAtBoot()` sends
|
|
188
|
+
* them the reason as a critical notification once notifications exist.
|
|
189
|
+
*/
|
|
190
|
+
markUnavailable(reason) {
|
|
191
|
+
this.unavailable = reason;
|
|
192
|
+
this.provider = null;
|
|
193
|
+
logger.error(`[DatabaseManager] ${reason}`);
|
|
194
|
+
this.markFailed(reason, 'ngdpbase.database.file');
|
|
195
|
+
// #1906: no message of its own, so visitors see the site's maintenance message.
|
|
196
|
+
this.engine.maintenance.enter(reason);
|
|
197
|
+
}
|
|
198
|
+
/** The reason for a database that did not open, with the fix that fits the key (#1892). */
|
|
199
|
+
static openFailure(file, cause, key) {
|
|
200
|
+
const keyFix = key
|
|
201
|
+
? `If ${DATABASE_KEY_ENV} is not the key this database was made with, correct ${DATABASE_KEY_ENV} in <FAST_STORAGE>/.env (or the environment) and restart.`
|
|
202
|
+
: `${DATABASE_KEY_ENV} is not set: if the database is encrypted, set ${DATABASE_KEY_ENV} in <FAST_STORAGE>/.env (or the environment) and restart.`;
|
|
203
|
+
return `The application database ${file} could not be opened safely: ${cause.replace(/\.$/, '')}. ` +
|
|
204
|
+
'It was not opened unencrypted, and nothing further was migrated or written. ' +
|
|
205
|
+
`${keyFix} If the file itself is damaged or a migration failed, restore the databases from a backup on Admin → Backup ` +
|
|
206
|
+
'(applied at the next restart; the current file is kept beside it as *.pre-restore), or stop the server, put back a good copy ' +
|
|
207
|
+
'of the database file, and restart (#1892)';
|
|
208
|
+
}
|
|
209
|
+
/** The refusal for anything that needs the application database while it is unavailable (#1892). */
|
|
210
|
+
unavailableRefusal(what) {
|
|
211
|
+
return `The application database is not available, so ${what} — the critical notification about it says why and how to fix it (#1892)`;
|
|
212
|
+
}
|
|
160
213
|
/**
|
|
161
214
|
* The open connection, for a row-storing provider's constructor. Throws when
|
|
162
215
|
* no database is configured, so a provider that needs one fails at boot with
|
|
@@ -185,6 +238,8 @@ class DatabaseManager extends BaseManager {
|
|
|
185
238
|
* set; nothing is opened in the clear.
|
|
186
239
|
*/
|
|
187
240
|
openAddonDatabase(owner, options) {
|
|
241
|
+
if (this.unavailable)
|
|
242
|
+
throw new Error(this.unavailableRefusal(`add-on database '${owner}' is not opened`));
|
|
188
243
|
if (!this.provider || !this.databaseFile) {
|
|
189
244
|
throw new Error(`Add-on '${owner}' needs a database, but none is open — set ngdpbase.database.provider to 'sqlite' (ngdp-accounting-addons#13)`);
|
|
190
245
|
}
|
|
@@ -208,11 +263,8 @@ class DatabaseManager extends BaseManager {
|
|
|
208
263
|
if (path.resolve(file) === path.resolve(this.databaseFile)) {
|
|
209
264
|
throw new Error(`Add-on '${owner}' would open the application database itself (${file}) (ngdp-accounting-addons#13)`);
|
|
210
265
|
}
|
|
266
|
+
// Integrity is checked by the provider before anything is written (#1892).
|
|
211
267
|
const provider = new SqliteDatabaseProvider(file, key, options.migrations, options.ledgerTable, { busyTimeoutMs: this.busyTimeoutMs });
|
|
212
|
-
if (!provider.integrityOk()) {
|
|
213
|
-
provider.close();
|
|
214
|
-
throw new Error(`Add-on database ${file} failed its integrity check (ngdp-accounting-addons#13)`);
|
|
215
|
-
}
|
|
216
268
|
this.addonDatabases.set(owner, { provider, ledgers: new Set([options.ledgerTable]) });
|
|
217
269
|
const { applied, skipped } = provider.migrations;
|
|
218
270
|
logger.info(`[DatabaseManager] Opened ${file} for ${owner} (${key ? 'encrypted' : 'NOT encrypted'}) — ledger ${options.ledgerTable}, migrations applied ${applied.length}, already present ${skipped}`);
|
|
@@ -242,6 +294,9 @@ class DatabaseManager extends BaseManager {
|
|
|
242
294
|
for (const declaration of this.declarations()) {
|
|
243
295
|
if (declaration.declaredBy !== slug || !declaration.enabled)
|
|
244
296
|
continue;
|
|
297
|
+
// #1892: its database sits beside the application database, under the same key.
|
|
298
|
+
if (this.unavailable)
|
|
299
|
+
return this.unavailableRefusal(`add-on '${slug}', which declares database '${declaration.owner}', is not loaded`);
|
|
245
300
|
const refusal = DatabaseManager.keyRefusal(declaration, key);
|
|
246
301
|
if (refusal)
|
|
247
302
|
return refusal;
|
|
@@ -270,6 +325,18 @@ class DatabaseManager extends BaseManager {
|
|
|
270
325
|
*/
|
|
271
326
|
async backup(options) {
|
|
272
327
|
const base = { managerName: 'DatabaseManager', timestamp: new Date().toISOString() };
|
|
328
|
+
// #1892: never a backup that silently leaves the databases out. A
|
|
329
|
+
// restore's safety backup (D2) leaves them out and says so: they can't be
|
|
330
|
+
// read, and the restore keeps their files as *.pre-restore.
|
|
331
|
+
if (this.unavailable && this.databaseFile) {
|
|
332
|
+
if (!options?.safetyBackup)
|
|
333
|
+
throw new Error(this.unavailableRefusal('no database was backed up'));
|
|
334
|
+
const leftOut = this.backupSources(this.databaseFile).map((s) => s.name);
|
|
335
|
+
const note = `The databases (${leftOut.join(', ')}) are left out of this safety backup: the application database could not be opened. A restore keeps their files beside the restored ones as *.pre-restore (#1892)`;
|
|
336
|
+
logger.warn(`[DatabaseManager] ${note}`);
|
|
337
|
+
const data = { format: 1, databases: [], absent: [], failed: [], leftOut: { databases: leftOut, reason: this.unavailable } };
|
|
338
|
+
return { ...base, data, note };
|
|
339
|
+
}
|
|
273
340
|
if (!this.provider || !this.databaseFile) {
|
|
274
341
|
return { ...base, data: null, note: 'No application database (ngdpbase.database.provider is none)' };
|
|
275
342
|
}
|
|
@@ -278,7 +345,7 @@ class DatabaseManager extends BaseManager {
|
|
|
278
345
|
}
|
|
279
346
|
const backupKey = this.backupKey();
|
|
280
347
|
if (!backupKey) {
|
|
281
|
-
const reason = `${
|
|
348
|
+
const reason = `${LEFT_OUT_FOR_NO_KEY}no database was backed up. Database backups are always encrypted and are never written without it (#1723)`;
|
|
282
349
|
this.fault('backup-key', reason);
|
|
283
350
|
throw new Error(reason);
|
|
284
351
|
}
|
|
@@ -342,7 +409,13 @@ class DatabaseManager extends BaseManager {
|
|
|
342
409
|
logger.info('[DatabaseManager] The backup holds no database; every database is left as it is (#1723)');
|
|
343
410
|
return;
|
|
344
411
|
}
|
|
345
|
-
|
|
412
|
+
// #1892 D2: a restore is how a damaged database is put right, so it is
|
|
413
|
+
// allowed while the application database can't be opened, but never
|
|
414
|
+
// without the key: what it stages would then be unencrypted.
|
|
415
|
+
if (this.unavailable && !(process.env[DATABASE_KEY_ENV] ?? '')) {
|
|
416
|
+
throw new Error(`The application database is not available and ${DATABASE_KEY_ENV} is not set, so nothing was staged: a restore now would put the databases back unencrypted. Set ${DATABASE_KEY_ENV} in <FAST_STORAGE>/.env (or the environment) and restart first; if that opens the database, no restore is needed (#1892)`);
|
|
417
|
+
}
|
|
418
|
+
if (!this.databaseFile || (!this.provider && !this.unavailable)) {
|
|
346
419
|
throw new Error("The backup holds databases, but this site has none open — set ngdpbase.database.provider to 'sqlite' first (#1723)");
|
|
347
420
|
}
|
|
348
421
|
if (!options?.directory) {
|
|
@@ -419,15 +492,51 @@ class DatabaseManager extends BaseManager {
|
|
|
419
492
|
databases: stages.map((s) => ({ name: s.name, live: s.live, staged: stagedPath(s.live) }))
|
|
420
493
|
};
|
|
421
494
|
writeManifest(manifestFile, manifest);
|
|
495
|
+
// #1876: part of a site restore — the databases go in with the other parts, or not at all.
|
|
496
|
+
if (options.siteRestore) {
|
|
497
|
+
recordStagedPart(options.siteRestore, {
|
|
498
|
+
manager: 'DatabaseManager',
|
|
499
|
+
swaps: manifest.databases.map((d) => ({ live: d.live, staged: d.staged })),
|
|
500
|
+
discard: [manifestFile]
|
|
501
|
+
});
|
|
502
|
+
}
|
|
422
503
|
const names = stages.map((s) => s.name).join(', ');
|
|
423
504
|
logger.warn(`[DatabaseManager] Restore staged for ${names} — restart to apply it (#1723)`);
|
|
424
|
-
|
|
505
|
+
const unopened = this.unavailable
|
|
506
|
+
? ' The application database could not be opened, so that safety backup leaves the databases out; the current database files are kept as *.pre-restore instead.'
|
|
507
|
+
: '';
|
|
508
|
+
await this.notify('warning', 'A database restore is staged', `The databases ${names} from the backup taken ${manifest.backup} are staged. Restart the site to put them in place; the current files are kept as *.pre-restore.${manifest.safetyBackup ? ` The current data was backed up first, as ${manifest.safetyBackup}.` : ''}${unopened}`);
|
|
425
509
|
if (skipped.length > 0) {
|
|
426
510
|
const message = `The backup also holds ${skipped.join(', ')}, which no add-on on this site declares, so they were not restored. Install the add-on and restore again to bring them back.`;
|
|
427
511
|
logger.error(`[DatabaseManager] ${message}`);
|
|
428
512
|
await this.notify('error', 'Some databases in the backup were not restored', message);
|
|
429
513
|
}
|
|
430
514
|
}
|
|
515
|
+
/** Whether a staged restore put the databases back at this start (#1876): the job state in them is then the backup's. */
|
|
516
|
+
restoredAtStart() {
|
|
517
|
+
return this.restoredNow.length > 0;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Read the application database as it was just before this start's restore
|
|
521
|
+
* replaced it (#1876): the kept `*.pre-restore` file, read-only, under the
|
|
522
|
+
* site's database key, which it was encrypted under. For the owner of data
|
|
523
|
+
* in it (BackgroundJobManager's job runs since the backup) to say what the
|
|
524
|
+
* restore took away. Null when no restore replaced it at this start.
|
|
525
|
+
*/
|
|
526
|
+
readReplacedApplicationDatabase(read) {
|
|
527
|
+
const kept = this.replaced?.applicationKept;
|
|
528
|
+
if (!kept || !existsSync(kept))
|
|
529
|
+
return null;
|
|
530
|
+
return SqliteDatabaseProvider.readOnce(kept, process.env[DATABASE_KEY_ENV] ?? '', (db) => read(db, this.replaced.backup));
|
|
531
|
+
}
|
|
532
|
+
/** The databases a staged restore put back at this start, by name (#1876). */
|
|
533
|
+
getRestoredAtStart() {
|
|
534
|
+
return [...this.restoredNow];
|
|
535
|
+
}
|
|
536
|
+
/** Its restore is staged for the next start (#1723), and joins a site restore (#1876). */
|
|
537
|
+
restoreIsStaged() {
|
|
538
|
+
return true;
|
|
539
|
+
}
|
|
431
540
|
/** The restore staged and waiting for the next start, or null. */
|
|
432
541
|
getStagedRestore() {
|
|
433
542
|
if (!this.databaseFile)
|
|
@@ -476,6 +585,9 @@ class DatabaseManager extends BaseManager {
|
|
|
476
585
|
* notifications do.
|
|
477
586
|
*/
|
|
478
587
|
async reportAtBoot() {
|
|
588
|
+
if (this.unavailable) {
|
|
589
|
+
await this.notify('error', 'Critical: the application database is not available', `${this.unavailable}. Until then the site is in maintenance mode: visitors see a short notice with no details, and administrators can still sign in and use /admin.`);
|
|
590
|
+
}
|
|
479
591
|
const keyProblem = this.provider ? this.backupKeyProblem() : null;
|
|
480
592
|
if (keyProblem)
|
|
481
593
|
await this.notify('error', 'Databases are not safely backed up', keyProblem);
|
|
@@ -507,7 +619,23 @@ class DatabaseManager extends BaseManager {
|
|
|
507
619
|
const name = path.basename(resolved);
|
|
508
620
|
return path.dirname(resolved) === dir && name.endsWith('.db') && ADDON_SLUG.test(name.slice(0, -3));
|
|
509
621
|
};
|
|
510
|
-
|
|
622
|
+
// #1876: a database restore staged as part of a site restore goes in only
|
|
623
|
+
// when the whole site restore does; otherwise it is set aside with it.
|
|
624
|
+
const siteFile = siteRestoreFile(this.engine.getManager('ConfigurationManager'));
|
|
625
|
+
const stagedBackupAt = this.getStagedRestore()?.backup ?? '';
|
|
626
|
+
const gate = gatePart(siteFile, 'DatabaseManager');
|
|
627
|
+
let outcome;
|
|
628
|
+
if (gate.decision === 'set-aside') {
|
|
629
|
+
outcome = setAsideStagedRestore(file, `the site restore it belongs to was not applied: ${gate.reason}`);
|
|
630
|
+
if (gate.part)
|
|
631
|
+
markPart(siteFile, 'DatabaseManager', 'set-aside', gate.reason);
|
|
632
|
+
}
|
|
633
|
+
else {
|
|
634
|
+
outcome = applyStagedRestore(file, allowed);
|
|
635
|
+
if (gate.decision === 'apply') {
|
|
636
|
+
markPart(siteFile, 'DatabaseManager', outcome && !outcome.refused ? 'applied' : 'set-aside', outcome?.refused ?? (outcome ? undefined : 'no staged database restore was found'));
|
|
637
|
+
}
|
|
638
|
+
}
|
|
511
639
|
if (!outcome)
|
|
512
640
|
return;
|
|
513
641
|
this.restoreAtStart = outcome;
|
|
@@ -517,12 +645,16 @@ class DatabaseManager extends BaseManager {
|
|
|
517
645
|
this.fault('restore', reason);
|
|
518
646
|
return;
|
|
519
647
|
}
|
|
648
|
+
this.restoredNow = outcome.applied.map((a) => a.name);
|
|
649
|
+
if (outcome.applied.length > 0) {
|
|
650
|
+
this.replaced = { backup: stagedBackupAt, applicationKept: outcome.applied.find((a) => path.resolve(a.live) === path.resolve(file))?.kept ?? null };
|
|
651
|
+
}
|
|
520
652
|
for (const a of outcome.applied) {
|
|
521
653
|
logger.warn(`[DatabaseManager] Restore applied: ${a.name} -> ${a.live} (previous kept as ${path.basename(a.kept)}) (#1723)`);
|
|
522
654
|
}
|
|
523
655
|
}
|
|
524
656
|
backupKey() {
|
|
525
|
-
return (
|
|
657
|
+
return currentBackupKey();
|
|
526
658
|
}
|
|
527
659
|
/** What is wrong with the backup key: missing, or the database key itself (D3). Null when sound. */
|
|
528
660
|
backupKeyProblem() {
|
|
@@ -566,8 +698,10 @@ class DatabaseManager extends BaseManager {
|
|
|
566
698
|
logger.error(`[DatabaseManager] The notification '${title}' could not be sent (#1723):`, err);
|
|
567
699
|
}
|
|
568
700
|
}
|
|
569
|
-
/** Whether every open database passes SQLite's quick check; true when none is configured. */
|
|
701
|
+
/** Whether every open database passes SQLite's quick check; true when none is configured, false when the configured one could not be opened (#1892). */
|
|
570
702
|
integrityOk() {
|
|
703
|
+
if (this.unavailable)
|
|
704
|
+
return false;
|
|
571
705
|
if (this.provider && !this.provider.integrityOk())
|
|
572
706
|
return false;
|
|
573
707
|
for (const { provider } of this.addonDatabases.values()) {
|
|
@@ -12,6 +12,14 @@
|
|
|
12
12
|
* Implements the MailProvider interface so it can be passed directly to
|
|
13
13
|
* MagicLinkAuthProvider without any changes to that class.
|
|
14
14
|
*
|
|
15
|
+
* A failed send is never silent (#1916). Every sender goes through `send()`,
|
|
16
|
+
* so it is reported here, once for all of them: logged (provider, recipient
|
|
17
|
+
* domain, subject, error — never the body or the full address), the manager
|
|
18
|
+
* shows degraded until the next send succeeds, and administrators get one
|
|
19
|
+
* notification per `ngdpbase.mail.failure-notice.window-minutes` carrying a
|
|
20
|
+
* running count, not one per email. `send()` still rejects, so a caller with
|
|
21
|
+
* a person waiting can tell them.
|
|
22
|
+
*
|
|
15
23
|
* @see docs/admin/email-setup.md for operator setup instructions
|
|
16
24
|
* @see {@link https://github.com/jwilleke/ngdpbase/issues/456}
|
|
17
25
|
*/
|
|
@@ -19,21 +27,55 @@ import BaseManager from './BaseManager.js';
|
|
|
19
27
|
import type { BackupData } from './BaseManager.js';
|
|
20
28
|
import type { WikiEngine } from '../types/WikiEngine.js';
|
|
21
29
|
import type { MailMessage, MailProvider } from '../mail/MailProvider.js';
|
|
30
|
+
/** The degraded reason while sends fail; cleared by the next success. */
|
|
31
|
+
export declare const SEND_FAILING_REASON = "outbound email is failing \u2014 see the server log and the administrator notification";
|
|
32
|
+
/** Title of the administrator notice. */
|
|
33
|
+
export declare const SEND_FAILING_TITLE = "Email could not be sent";
|
|
22
34
|
declare class EmailManager extends BaseManager implements MailProvider {
|
|
23
35
|
private provider;
|
|
24
36
|
private providerName;
|
|
25
37
|
private from;
|
|
26
38
|
private enabled;
|
|
39
|
+
private noticeWindowMs;
|
|
40
|
+
private notice;
|
|
41
|
+
private failuresSinceSuccess;
|
|
27
42
|
constructor(engine: WikiEngine);
|
|
28
43
|
initialize(config?: Record<string, unknown>): Promise<void>;
|
|
29
44
|
/**
|
|
30
45
|
* Send an email. Injects the configured from address if the message omits it.
|
|
46
|
+
*
|
|
47
|
+
* A failure is reported here (#1916) and then rethrown, so a caller with a
|
|
48
|
+
* person waiting can tell them it was not sent.
|
|
31
49
|
*/
|
|
32
50
|
send(message: MailMessage): Promise<void>;
|
|
33
51
|
/**
|
|
34
52
|
* Convenience wrapper — build and send a message in one call.
|
|
35
53
|
*/
|
|
36
54
|
sendTo(to: string, subject: string, text: string, html?: string): Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Send without waiting, for a sender nobody is waiting on (a form's copy, a
|
|
57
|
+
* reservation notice). Never rejects: a failure is already logged and
|
|
58
|
+
* reported to administrators by `send()` (#1916), so there is nothing left
|
|
59
|
+
* for the caller to do with it.
|
|
60
|
+
*/
|
|
61
|
+
sendInBackground(to: string, subject: string, text: string, html?: string): void;
|
|
62
|
+
/** Whether sends are failing right now: degraded by a failed send, not yet cleared by a success. */
|
|
63
|
+
isDeliveryFailing(): boolean;
|
|
64
|
+
private reportFailure;
|
|
65
|
+
private reportSuccess;
|
|
66
|
+
/**
|
|
67
|
+
* One notice per window, with a running count (#1916). A failure inside an
|
|
68
|
+
* open window updates that notice; the first failure after it opens a new
|
|
69
|
+
* one. The notice is created with `escalate: false`, so it is never emailed:
|
|
70
|
+
* that send would fail and report another failure.
|
|
71
|
+
*
|
|
72
|
+
* Never throws — it runs off the failed send, and a notice that cannot be
|
|
73
|
+
* written is already covered by the error log above.
|
|
74
|
+
*/
|
|
75
|
+
private noticeAdministrators;
|
|
76
|
+
private createNotice;
|
|
77
|
+
private noticeMessage;
|
|
78
|
+
private notifications;
|
|
37
79
|
/** Name of the active provider: 'console' | 'smtp' */
|
|
38
80
|
getProviderName(): string;
|
|
39
81
|
/** Effective from address (smtp.from or mail.from) */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import BaseManager from './BaseManager.js';
|
|
1
|
+
import BaseManager, { type BackupData, type ManagerBackupOptions, type ManagerRestoreOptions } from './BaseManager.js';
|
|
2
2
|
import type { WikiEngine } from '../types/WikiEngine.js';
|
|
3
3
|
import { type ActorContext } from '../context/ActorContext.js';
|
|
4
4
|
import { type VaultPageNotesOutside, type PageRef } from '../utils/pageNotes.js';
|
|
@@ -17,6 +17,15 @@ export default class FootnoteManager extends BaseManager {
|
|
|
17
17
|
private readonly outsideCheck;
|
|
18
18
|
constructor(engine: WikiEngine);
|
|
19
19
|
initialize(): Promise<void>;
|
|
20
|
+
/**
|
|
21
|
+
* The public folder is in the backup (#1876), as `<stem>.footnotes.tar` beside
|
|
22
|
+
* the document: the public pages' footnotes. A vault page's are in its vault, which
|
|
23
|
+
* PageManager backs up with the vault, sealed as stored.
|
|
24
|
+
*/
|
|
25
|
+
backup(options?: ManagerBackupOptions): Promise<BackupData>;
|
|
26
|
+
/** Staged for the next start with the rest of a site restore (#1876); the live folder is untouched. */
|
|
27
|
+
restore(backupData: BackupData, options?: ManagerRestoreOptions): Promise<void>;
|
|
28
|
+
restoreIsStaged(): boolean;
|
|
20
29
|
isEnabled(): boolean;
|
|
21
30
|
/** The public folder and the vaults, as configured now (#1839). */
|
|
22
31
|
private roots;
|
|
@@ -25,6 +25,12 @@ export interface NotificationInput {
|
|
|
25
25
|
level?: 'info' | 'warning' | 'error' | 'success';
|
|
26
26
|
targetUsers?: string[];
|
|
27
27
|
expiresAt?: Date | null;
|
|
28
|
+
/**
|
|
29
|
+
* False: never email this notification, whatever the escalation settings
|
|
30
|
+
* (#1916). A notice that email is failing must not itself be emailed — the
|
|
31
|
+
* send would fail and report another failure. Not stored.
|
|
32
|
+
*/
|
|
33
|
+
escalate?: boolean;
|
|
28
34
|
}
|
|
29
35
|
/**
|
|
30
36
|
* Notification statistics
|
|
@@ -102,6 +108,13 @@ declare class NotificationManager extends BaseManager {
|
|
|
102
108
|
* @returns {Promise<string>} Notification ID
|
|
103
109
|
*/
|
|
104
110
|
createNotification(notification: NotificationInput): Promise<string>;
|
|
111
|
+
/**
|
|
112
|
+
* Replace an active notification's message in place (#1916) — a running
|
|
113
|
+
* count on one notice instead of a new notice per event. Never escalates:
|
|
114
|
+
* the notification was already escalated, or not, when it was created.
|
|
115
|
+
* @returns false when there is no such notification
|
|
116
|
+
*/
|
|
117
|
+
updateNotificationMessage(notificationId: string, message: string): Promise<boolean>;
|
|
105
118
|
/**
|
|
106
119
|
* Add a notification (alias for createNotification for backward compatibility)
|
|
107
120
|
* @param {NotificationInput} notification - Notification object
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import BaseManager, { type ManagerStats } from './BaseManager.js';
|
|
1
|
+
import BaseManager, { type BackupData, type ManagerBackupOptions, type ManagerRestoreOptions, type ManagerStats } from './BaseManager.js';
|
|
2
2
|
import type { WikiEngine } from '../types/WikiEngine.js';
|
|
3
|
-
import type { Organization, OrganizationUpdate } from '../types/Organization.js';
|
|
3
|
+
import type { Organization, OrganizationInput, OrganizationUpdate } from '../types/Organization.js';
|
|
4
4
|
import type { OrganizationProvider } from '../types/OrganizationProvider.js';
|
|
5
|
+
import type { Person } from '../types/Person.js';
|
|
6
|
+
import type { ActorContext } from '../context/ActorContext.js';
|
|
5
7
|
/**
|
|
6
8
|
* Subset of install-form data this manager needs to seed the anchor org.
|
|
7
9
|
* Kept narrow so InstallService and tests can pass plain objects without
|
|
@@ -34,6 +36,8 @@ declare class OrganizationManager extends BaseManager {
|
|
|
34
36
|
readonly description = "Canonical Organization records (#617)";
|
|
35
37
|
private provider;
|
|
36
38
|
private providerClass?;
|
|
39
|
+
/** `ngdpbase.application.organization.storagedir`, resolved at start: what a backup copies (#1876). */
|
|
40
|
+
private storageDir;
|
|
37
41
|
private installOrgCache;
|
|
38
42
|
private byIdCache;
|
|
39
43
|
private byFileCache;
|
|
@@ -41,6 +45,11 @@ declare class OrganizationManager extends BaseManager {
|
|
|
41
45
|
constructor(engine: WikiEngine);
|
|
42
46
|
initialize(config?: Record<string, unknown>): Promise<void>;
|
|
43
47
|
/** Provider accessor, mainly for tests. */
|
|
48
|
+
/** Organization records are in the backup (#1876), as `<stem>.organizations.tar` beside the document. */
|
|
49
|
+
backup(options?: ManagerBackupOptions): Promise<BackupData>;
|
|
50
|
+
/** Staged for the next start with the rest of a site restore (#1876); the live records are untouched. */
|
|
51
|
+
restore(backupData: BackupData, options?: ManagerRestoreOptions): Promise<void>;
|
|
52
|
+
restoreIsStaged(): boolean;
|
|
44
53
|
getProvider(): OrganizationProvider | null;
|
|
45
54
|
/**
|
|
46
55
|
* Create the anchor organization from the instance's own identity (#1027).
|
|
@@ -81,10 +90,35 @@ declare class OrganizationManager extends BaseManager {
|
|
|
81
90
|
getByFile(filename: string): Promise<Organization | null>;
|
|
82
91
|
/** #1006: how many organizations this instance holds. Count only. */
|
|
83
92
|
getManagerStats(): Promise<ManagerStats>;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
93
|
+
/** Every organization, for a caller who may read contacts (`contact-read`). */
|
|
94
|
+
list(ctx: ActorContext): Promise<Organization[]>;
|
|
95
|
+
/** One organization by `@id`, for a caller who may read contacts (`contact-read`). */
|
|
96
|
+
get(ctx: ActorContext, id: string): Promise<Organization | null>;
|
|
97
|
+
/**
|
|
98
|
+
* Organizations whose `name`, `legalName`, `email` or a contact point's
|
|
99
|
+
* email contains `query`, ignoring case; an empty query matches all. At
|
|
100
|
+
* most `limit` (`ngdpbase.application.organization.search.limit` by default).
|
|
101
|
+
*/
|
|
102
|
+
search(ctx: ActorContext, query: string, options?: {
|
|
103
|
+
limit?: number;
|
|
104
|
+
}): Promise<Organization[]>;
|
|
105
|
+
/**
|
|
106
|
+
* The people affiliated with an organization, read from each person's
|
|
107
|
+
* `affiliation` (#1930): the link is stored once, on the person. Each comes
|
|
108
|
+
* with the role they hold there, when one is given.
|
|
109
|
+
*/
|
|
110
|
+
people(ctx: ActorContext, organizationId: string): Promise<Array<{
|
|
111
|
+
person: Person;
|
|
112
|
+
role?: string;
|
|
113
|
+
}>>;
|
|
114
|
+
/**
|
|
115
|
+
* Create an organization. `name` is required; `@id` defaults to the `url`,
|
|
116
|
+
* else a new `urn:uuid:`. `filename` is for the install anchor (seeding);
|
|
117
|
+
* other records take one from the url or name.
|
|
118
|
+
*/
|
|
119
|
+
create(ctx: ActorContext, org: OrganizationInput, filename?: string): Promise<Organization>;
|
|
120
|
+
update(ctx: ActorContext, id: string, patch: OrganizationUpdate): Promise<Organization | null>;
|
|
121
|
+
delete(ctx: ActorContext, id: string): Promise<boolean>;
|
|
88
122
|
/**
|
|
89
123
|
* Clear all caches. Called on every write — the simplest correct
|
|
90
124
|
* invalidation. Per-key invalidation can come later if needed.
|