@jwilleke/ngdpbase-types 0.0.0 → 5.19.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/LICENSE +201 -0
- package/README.md +16 -2
- package/lib/packages/ngdpbase-types/index.d.ts +32 -0
- package/lib/packages/ngdpbase-types/testing.d.ts +43 -0
- package/lib/packages/ngdpbase-types/testing.js +53 -0
- package/lib/src/cache/CacheDependencies.d.ts +50 -0
- package/lib/src/cache/CacheDependencies.js +50 -0
- package/lib/src/cache/ICacheAdapter.d.ts +86 -0
- package/lib/src/cache/RegionCache.d.ts +171 -0
- package/lib/src/context/ActorContext.d.ts +48 -0
- package/lib/src/context/ActorContext.js +33 -0
- package/lib/src/context/ApiContext.d.ts +194 -0
- package/lib/src/context/ApiContext.js +273 -0
- package/lib/src/context/BaseContext.d.ts +110 -0
- package/lib/src/context/BaseContext.js +143 -0
- package/lib/src/context/JobContext.d.ts +153 -0
- package/lib/src/context/JobContext.js +144 -0
- package/lib/src/context/WikiContext.d.ts +404 -0
- package/lib/src/context/bootActions.d.ts +64 -0
- package/lib/src/context/bootActions.js +111 -0
- package/lib/src/converters/HtmlConverter.d.ts +70 -0
- package/lib/src/converters/IContentConverter.d.ts +94 -0
- package/lib/src/converters/JSPWikiConverter.d.ts +117 -0
- package/lib/src/converters/conversionWarning.d.ts +15 -0
- package/lib/src/converters/ncm/bridge.d.ts +22 -0
- package/lib/src/converters/ncm/existing.d.ts +23 -0
- package/lib/src/converters/ncm/fix/blocks.d.ts +89 -0
- package/lib/src/converters/ncm/fix/bulletMarkers.d.ts +21 -0
- package/lib/src/converters/ncm/fix/index.d.ts +36 -0
- package/lib/src/converters/ncm/fix/jspwikiBullets.d.ts +20 -0
- package/lib/src/converters/ncm/fix/jspwikiCodeMarkers.d.ts +38 -0
- package/lib/src/converters/ncm/fix/jspwikiHeadings.d.ts +25 -0
- package/lib/src/converters/ncm/fix/jspwikiIndent.d.ts +24 -0
- package/lib/src/converters/ncm/fix/jspwikiInsert.d.ts +21 -0
- package/lib/src/converters/ncm/fix/jspwikiItalic.d.ts +29 -0
- package/lib/src/converters/ncm/fix/jspwikiLinkLeftovers.d.ts +27 -0
- package/lib/src/converters/ncm/fix/moreInformationFooter.d.ts +25 -0
- package/lib/src/converters/ncm/fix/styleClosers.d.ts +25 -0
- package/lib/src/converters/ncm/fix/tightenLists.d.ts +30 -0
- package/lib/src/converters/ncm/fix/types.d.ts +33 -0
- package/lib/src/converters/ncm/footnotes.d.ts +39 -0
- package/lib/src/converters/ncm/images.d.ts +49 -0
- package/lib/src/converters/ncm/index.d.ts +20 -0
- package/lib/src/converters/ncm/links.d.ts +21 -0
- package/lib/src/converters/ncm/normalize.d.ts +39 -0
- package/lib/src/converters/ncm/placeholder.d.ts +25 -0
- package/lib/src/converters/ncm/tables.d.ts +36 -0
- package/lib/src/converters/ncm/types.d.ts +42 -0
- package/lib/src/http/egressPolicy.d.ts +52 -0
- package/lib/src/http/guardedFetch.d.ts +89 -0
- package/lib/src/http/guardedFetch.js +113 -0
- package/lib/src/http/guardedLookup.d.ts +48 -0
- package/lib/src/http/guardedLookup.js +73 -0
- package/lib/src/http/ssrf.d.ts +83 -0
- package/lib/src/http/ssrf.js +311 -0
- package/lib/src/mail/MailProvider.d.ts +36 -0
- package/lib/src/mail/NodemailerMailProvider.d.ts +26 -0
- package/lib/src/managers/AddonsManager.d.ts +691 -0
- package/lib/src/managers/AgentTokenManager.d.ts +269 -0
- package/lib/src/managers/AssetManager.d.ts +116 -0
- package/lib/src/managers/AttachmentManager.d.ts +1032 -0
- package/lib/src/managers/AuthManager.d.ts +1257 -0
- package/lib/src/managers/BackgroundJobManager.d.ts +434 -0
- package/lib/src/managers/BaseManager.d.ts +427 -0
- package/lib/src/managers/BaseManager.js +415 -0
- package/lib/src/managers/CacheManager.d.ts +159 -0
- package/lib/src/managers/CatalogManager.d.ts +235 -0
- package/lib/src/managers/CommentManager.d.ts +75 -0
- package/lib/src/managers/ConfigurationManager.d.ts +747 -0
- package/lib/src/managers/DatabaseManager.d.ts +188 -0
- package/lib/src/managers/DatabaseManager.js +590 -0
- package/lib/src/managers/EmailManager.d.ts +47 -0
- package/lib/src/managers/ExportManager.d.ts +129 -0
- package/lib/src/managers/ExportManager.js +420 -0
- package/lib/src/managers/FootnoteManager.d.ts +118 -0
- package/lib/src/managers/MediaManager.d.ts +279 -0
- package/lib/src/managers/MetricsManager.d.ts +89 -0
- package/lib/src/managers/NotificationManager.d.ts +165 -0
- package/lib/src/managers/OidcManager.d.ts +210 -0
- package/lib/src/managers/OrganizationManager.d.ts +107 -0
- package/lib/src/managers/PageManager.d.ts +1645 -0
- package/lib/src/managers/PersonManager.d.ts +48 -0
- package/lib/src/managers/PluginManager.d.ts +179 -0
- package/lib/src/managers/PluginManager.js +393 -0
- package/lib/src/managers/PolicyEvaluator.d.ts +213 -0
- package/lib/src/managers/RenderingManager.d.ts +358 -0
- package/lib/src/managers/RoleManager.d.ts +134 -0
- package/lib/src/managers/SearchManager.d.ts +402 -0
- package/lib/src/managers/ShareManager.d.ts +251 -0
- package/lib/src/managers/TemplateManager.d.ts +176 -0
- package/lib/src/managers/ThemeManager.d.ts +56 -0
- package/lib/src/managers/UserManager.d.ts +480 -0
- package/lib/src/managers/ValidationManager.d.ts +426 -0
- package/lib/src/managers/VariableManager.d.ts +145 -0
- package/lib/src/middleware/csrf.d.ts +3 -0
- package/lib/src/parsers/LinkParser.d.ts +457 -0
- package/lib/src/parsers/MarkupParser.d.ts +769 -0
- package/lib/src/parsers/context/ParseContext.d.ts +314 -0
- package/lib/src/parsers/data/emoji-map.d.ts +18 -0
- package/lib/src/parsers/dom/DOMBuilder.d.ts +210 -0
- package/lib/src/parsers/dom/DOMParser.d.ts +252 -0
- package/lib/src/parsers/dom/Tokenizer.d.ts +358 -0
- package/lib/src/parsers/dom/WikiDocument.d.ts +272 -0
- package/lib/src/parsers/dom/handlers/DOMLinkHandler.d.ts +260 -0
- package/lib/src/parsers/dom/handlers/DOMPluginHandler.d.ts +212 -0
- package/lib/src/parsers/dom/handlers/DOMVariableHandler.d.ts +171 -0
- package/lib/src/parsers/filters/BaseFilter.d.ts +269 -0
- package/lib/src/parsers/filters/FilterChain.d.ts +320 -0
- package/lib/src/parsers/handlers/BaseSyntaxHandler.d.ts +331 -0
- package/lib/src/parsers/handlers/HandlerRegistry.d.ts +225 -0
- package/lib/src/parsers/handlers/JSPWikiPreprocessor.d.ts +89 -0
- package/lib/src/parsers/handlers/LinkParserHandler.d.ts +80 -0
- package/lib/src/parsers/handlers/PluginSyntaxHandler.d.ts +78 -0
- package/lib/src/parsers/handlers/WikiFormHandler.d.ts +149 -0
- package/lib/src/parsers/handlers/WikiTagHandler.d.ts +125 -0
- package/lib/src/parsers/jspwikiTableRow.d.ts +39 -0
- package/lib/src/parsers/styleBlockSyntax.d.ts +14 -0
- package/lib/src/plugins/types.d.ts +79 -0
- package/lib/src/providers/AgentTokenAuthProvider.d.ts +40 -0
- package/lib/src/providers/AuthentikBearerAuthProvider.d.ts +75 -0
- package/lib/src/providers/BaseAttachmentProvider.d.ts +197 -0
- package/lib/src/providers/BaseAuthProvider.d.ts +220 -0
- package/lib/src/providers/BaseCacheProvider.d.ts +140 -0
- package/lib/src/providers/BaseCredentialsProvider.d.ts +84 -0
- package/lib/src/providers/BaseDatabaseProvider.d.ts +22 -0
- package/lib/src/providers/BaseDatabaseProvider.js +12 -0
- package/lib/src/providers/BaseLoggingProvider.d.ts +68 -0
- package/lib/src/providers/BaseLoggingProvider.js +37 -0
- package/lib/src/providers/BaseMediaProvider.d.ts +308 -0
- package/lib/src/providers/BasePageProvider.d.ts +337 -0
- package/lib/src/providers/BaseProvider.d.ts +98 -0
- package/lib/src/providers/BaseProvider.js +74 -0
- package/lib/src/providers/BaseSearchProvider.d.ts +362 -0
- package/lib/src/providers/BasicAttachmentProvider.d.ts +599 -0
- package/lib/src/providers/CloudflareAccessAuthProvider.d.ts +72 -0
- package/lib/src/providers/FileCredentialsProvider.d.ts +33 -0
- package/lib/src/providers/FileJobStateProvider.d.ts +58 -0
- package/lib/src/providers/FileLoggingProvider.d.ts +43 -0
- package/lib/src/providers/FileLoggingProvider.js +120 -0
- package/lib/src/providers/FileOidcAdapter.d.ts +25 -0
- package/lib/src/providers/FileSystemMediaProvider.d.ts +403 -0
- package/lib/src/providers/LunrSearchProvider.d.ts +280 -0
- package/lib/src/providers/MagicLinkAuthProvider.d.ts +125 -0
- package/lib/src/providers/NullCacheProvider.d.ts +72 -0
- package/lib/src/providers/OidcAuthProvider.d.ts +180 -0
- package/lib/src/providers/OidcBearerAuthProvider.d.ts +30 -0
- package/lib/src/providers/PasskeyAuthProvider.d.ts +58 -0
- package/lib/src/providers/PasswordAuthProvider.d.ts +20 -0
- package/lib/src/providers/SqliteDatabaseProvider.d.ts +55 -0
- package/lib/src/providers/SqliteDatabaseProvider.js +90 -0
- package/lib/src/providers/SqliteJobStateProvider.d.ts +41 -0
- package/lib/src/providers/databaseMigrations.d.ts +8 -0
- package/lib/src/providers/databaseMigrations.js +47 -0
- package/lib/src/providers/jobState.d.ts +99 -0
- package/lib/src/providers/lunrQueryToMiniSearch.d.ts +32 -0
- package/lib/src/providers/mediaFilenameDate.d.ts +22 -0
- package/lib/src/providers/sqliteBackup.d.ts +128 -0
- package/lib/src/providers/sqliteBackup.js +365 -0
- package/lib/src/providers/sqliteLocation.d.ts +17 -0
- package/lib/src/providers/sqliteLocation.js +55 -0
- package/lib/src/providers/sqliteMigrations.d.ts +48 -0
- package/lib/src/providers/sqliteMigrations.js +72 -0
- package/lib/src/rendering/githubAlerts.d.ts +22 -0
- package/lib/src/rendering/htmlPolicy.d.ts +76 -0
- package/lib/src/rendering/markdownConverter.d.ts +73 -0
- package/lib/src/security/PolicyDecisionPoint.d.ts +245 -0
- package/lib/src/security/PolicyInformationPoint.d.ts +448 -0
- package/lib/src/security/pageActions.d.ts +40 -0
- package/lib/src/security/permissions.generated.d.ts +78 -0
- package/lib/src/types/Asset.d.ts +414 -0
- package/lib/src/types/Catalog.d.ts +61 -0
- package/lib/src/types/Comment.d.ts +11 -0
- package/lib/src/types/Config.d.ts +388 -0
- package/lib/src/types/Organization.d.ts +78 -0
- package/lib/src/types/OrganizationProvider.d.ts +38 -0
- package/lib/src/types/Page.d.ts +220 -0
- package/lib/src/types/Person.d.ts +56 -0
- package/lib/src/types/PersonProvider.d.ts +25 -0
- package/lib/src/types/Policy.d.ts +139 -0
- package/lib/src/types/Policy.js +17 -0
- package/lib/src/types/Provider.d.ts +810 -0
- package/lib/src/types/Role.d.ts +84 -0
- package/lib/src/types/RoleProvider.d.ts +35 -0
- package/lib/src/types/Schema.d.ts +425 -0
- package/lib/src/types/Share.d.ts +172 -0
- package/lib/src/types/Share.js +81 -0
- package/lib/src/types/User.d.ts +304 -0
- package/lib/src/types/Version.d.ts +189 -0
- package/lib/src/types/WikiEngine.d.ts +128 -0
- package/lib/src/types/express.d.ts +120 -0
- package/lib/src/types/express.js +1 -0
- package/lib/src/types/guards.d.ts +78 -0
- package/lib/src/types/heic-decode.d.ts +16 -0
- package/lib/src/types/index.d.ts +19 -0
- package/lib/src/types/linkedom.d.ts +77 -0
- package/lib/src/types/markdown-it-plugins.d.ts +21 -0
- package/lib/src/utils/DeltaStorage.d.ts +118 -0
- package/lib/src/utils/LocaleUtils.d.ts +105 -0
- package/lib/src/utils/LocaleUtils.js +294 -0
- package/lib/src/utils/PageNameMatcher.d.ts +98 -0
- package/lib/src/utils/PathPreflight.d.ts +38 -0
- package/lib/src/utils/PathPreflight.js +50 -0
- package/lib/src/utils/SectionUtils.d.ts +40 -0
- package/lib/src/utils/addonConfigLayer.d.ts +144 -0
- package/lib/src/utils/addonConfigLayer.js +382 -0
- package/lib/src/utils/addonDatabaseRules.d.ts +25 -0
- package/lib/src/utils/addonDatabaseRules.js +57 -0
- package/lib/src/utils/addonHost.d.ts +51 -0
- package/lib/src/utils/addonHost.js +72 -0
- package/lib/src/utils/addonInstaller.d.ts +109 -0
- package/lib/src/utils/addonLock.d.ts +37 -0
- package/lib/src/utils/addonLock.js +48 -0
- package/lib/src/utils/addonPageSync.d.ts +45 -0
- package/lib/src/utils/addonProvenance.d.ts +48 -0
- package/lib/src/utils/addonTarball.d.ts +41 -0
- package/lib/src/utils/addonsPathResolver.d.ts +76 -0
- package/lib/src/utils/addonsPathResolver.js +129 -0
- package/lib/src/utils/assetMetadataTags.d.ts +66 -0
- package/lib/src/utils/atomicWrite.d.ts +28 -0
- package/lib/src/utils/auditConfigChange.d.ts +57 -0
- package/lib/src/utils/auditEventNames.d.ts +50 -0
- package/lib/src/utils/auditEventNames.generated.d.ts +169 -0
- package/lib/src/utils/auditEventNames.generated.js +174 -0
- package/lib/src/utils/auditEventNames.js +49 -0
- package/lib/src/utils/auditEvents.d.ts +265 -0
- package/lib/src/utils/auditEvents.js +309 -0
- package/lib/src/utils/auditRegistry.d.ts +102 -0
- package/lib/src/utils/auditRegistry.js +168 -0
- package/lib/src/utils/bip39English.d.ts +2 -0
- package/lib/src/utils/configEnvKeys.d.ts +68 -0
- package/lib/src/utils/configFiles.d.ts +74 -0
- package/lib/src/utils/configFiles.js +150 -0
- package/lib/src/utils/delegation.d.ts +48 -0
- package/lib/src/utils/deriveUsername.d.ts +21 -0
- package/lib/src/utils/explainMediaPath.d.ts +123 -0
- package/lib/src/utils/formerTitles.d.ts +69 -0
- package/lib/src/utils/frontmatterAccess.d.ts +54 -0
- package/lib/src/utils/headlessAdminPassword.d.ts +55 -0
- package/lib/src/utils/imageTransform.d.ts +52 -0
- package/lib/src/utils/instanceEnvSecret.d.ts +51 -0
- package/lib/src/utils/jobSlots.d.ts +43 -0
- package/lib/src/utils/keywordNormalizer.d.ts +127 -0
- package/lib/src/utils/logger.d.ts +70 -0
- package/lib/src/utils/logger.js +106 -0
- package/lib/src/utils/magicLinkDeviceState.d.ts +95 -0
- package/lib/src/utils/managerUtils.d.ts +40 -0
- package/lib/src/utils/migrateLegacyPrivateAttachments.d.ts +15 -0
- package/lib/src/utils/orgFilename.d.ts +22 -0
- package/lib/src/utils/pageFeature.d.ts +27 -0
- package/lib/src/utils/pageFrontmatter.d.ts +21 -0
- package/lib/src/utils/pageNotes.d.ts +150 -0
- package/lib/src/utils/pageTitleRule.d.ts +37 -0
- package/lib/src/utils/pageToArticle.d.ts +59 -0
- package/lib/src/utils/pageUrl.d.ts +18 -0
- package/lib/src/utils/passwordChange.d.ts +12 -0
- package/lib/src/utils/passwordHash.d.ts +60 -0
- package/lib/src/utils/permissionCatalog.d.ts +52 -0
- package/lib/src/utils/pluginFormatters.d.ts +452 -0
- package/lib/src/utils/pluginFormatters.js +714 -0
- package/lib/src/utils/privateLinkRewrite.d.ts +42 -0
- package/lib/src/utils/privateStoreAccess.d.ts +57 -0
- package/lib/src/utils/privateStoreAccess.js +65 -0
- package/lib/src/utils/privateStoreCatalogs.d.ts +40 -0
- package/lib/src/utils/privateStoreCrypto.d.ts +127 -0
- package/lib/src/utils/privateStoreDoor.d.ts +181 -0
- package/lib/src/utils/privateStoreExport.d.ts +72 -0
- package/lib/src/utils/privateStoreFiles.d.ts +120 -0
- package/lib/src/utils/privateStoreMeta.d.ts +64 -0
- package/lib/src/utils/privateStoreMigrations.d.ts +37 -0
- package/lib/src/utils/privateStorePath.d.ts +259 -0
- package/lib/src/utils/privateStorePath.js +436 -0
- package/lib/src/utils/privateStoreTakeout.d.ts +76 -0
- package/lib/src/utils/privateStoreUnlock.d.ts +175 -0
- package/lib/src/utils/proxyClaim.d.ts +59 -0
- package/lib/src/utils/proxyClaim.js +30 -0
- package/lib/src/utils/redactPrivateNames.d.ts +21 -0
- package/lib/src/utils/redactPrivateNames.js +89 -0
- package/lib/src/utils/redactSecrets.d.ts +58 -0
- package/lib/src/utils/redactSecrets.js +171 -0
- package/lib/src/utils/renameLinkRewrite.d.ts +112 -0
- package/lib/src/utils/roleGrants.d.ts +35 -0
- package/lib/src/utils/safeRedirect.d.ts +32 -0
- package/lib/src/utils/schedule.d.ts +54 -0
- package/lib/src/utils/secretFileMode.d.ts +17 -0
- package/lib/src/utils/seededShippedPages.d.ts +45 -0
- package/lib/src/utils/sensitiveValues.d.ts +59 -0
- package/lib/src/utils/sensitiveValues.js +106 -0
- package/lib/src/utils/sessionEnd.d.ts +12 -0
- package/lib/src/utils/sessionGeneration.d.ts +24 -0
- package/lib/src/utils/sessionIdle.d.ts +41 -0
- package/lib/src/utils/sessionLimits.d.ts +68 -0
- package/lib/src/utils/shareLockbox.d.ts +64 -0
- package/lib/src/utils/sigstoreTrustedRoot.d.ts +33 -0
- package/lib/src/utils/sniffMime.d.ts +21 -0
- package/lib/src/utils/storeSearchIndex.d.ts +75 -0
- package/lib/src/utils/subjectMayDo.d.ts +21 -0
- package/lib/src/utils/systemCategoryRules.d.ts +30 -0
- package/lib/src/utils/systemCategoryRules.js +41 -0
- package/lib/src/utils/userCreateError.d.ts +62 -0
- package/lib/src/utils/username.d.ts +53 -0
- package/lib/src/utils/vaultUrlRewrite.d.ts +27 -0
- package/lib/src/utils/version.d.ts +84 -0
- package/lib/src/utils/videoFrame.d.ts +21 -0
- package/package.json +54 -3
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DatabaseManager — the one door to the application database (#1536).
|
|
3
|
+
*
|
|
4
|
+
* Ported from yourPHR (yourphr#617), where it is in production. A manager that
|
|
5
|
+
* stores rows is handed this connection through `getHandle()`; nothing opens a
|
|
6
|
+
* database handle of its own.
|
|
7
|
+
*
|
|
8
|
+
* `ngdpbase.database.provider` chooses the provider:
|
|
9
|
+
* - `none` (the default) — no database is opened. Nothing in core stores rows
|
|
10
|
+
* yet; the first will be the database audit provider (#1537). Opening a file
|
|
11
|
+
* with no reader would only add a way to fail at boot — on a data volume
|
|
12
|
+
* that is a network filesystem, for instance.
|
|
13
|
+
* - `sqlite` — one file at `ngdpbase.database.file`, encrypted with SQLCipher
|
|
14
|
+
* when `NGDPBASE_DATABASE_KEY` is set (in the environment or the instance
|
|
15
|
+
* `.env`), never from config. Without a key it opens unencrypted and says so.
|
|
16
|
+
*
|
|
17
|
+
* Boot refuses, naming the cause, when the file would sit on a network
|
|
18
|
+
* filesystem, when the schema is from a newer build, when a migration fails,
|
|
19
|
+
* or when the integrity check does not pass.
|
|
20
|
+
*
|
|
21
|
+
* An add-on that keeps rows gets a database of its own the same way
|
|
22
|
+
* (`openAddonDatabase`, ngdp-accounting-addons#13): its own file beside the
|
|
23
|
+
* application database, opened by the same provider under the same key, with
|
|
24
|
+
* its own migration ledger. A dependent add-on adds its ledger to that file.
|
|
25
|
+
* Only a declared database is opened (#1860): the add-on names it in its own
|
|
26
|
+
* config/default-config.json (`ngdpbase.addons.<slug>.database`), and one
|
|
27
|
+
* declared `encrypt: "required"` is never opened without the key.
|
|
28
|
+
*
|
|
29
|
+
* Backup and restore (#1723): `backup()` and `restore()` are the ones
|
|
30
|
+
* BackupManager calls on every manager, so add-ons write no backup code. A
|
|
31
|
+
* backup copies the application database and every declared add-on database,
|
|
32
|
+
* on or off, at one point in time, on a worker thread, each into its own file
|
|
33
|
+
* beside BackupManager's document, encrypted under `NGDPBASE_BACKUP_KEY`. A
|
|
34
|
+
* restore stages `<file>.staged` beside each database, all or nothing, and the
|
|
35
|
+
* next start puts them in place before any database opens.
|
|
36
|
+
*/
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import BaseManager from './BaseManager.js';
|
|
39
|
+
import SqliteDatabaseProvider, { DEFAULT_BUSY_TIMEOUT_MS } from '../providers/SqliteDatabaseProvider.js';
|
|
40
|
+
import DATABASE_MIGRATIONS from '../providers/databaseMigrations.js';
|
|
41
|
+
import { createHmac } from 'node:crypto';
|
|
42
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
43
|
+
import { actorOf } from '../context/ActorContext.js';
|
|
44
|
+
import { recordAuditEvent } from '../utils/auditEvents.js';
|
|
45
|
+
import { AUDIT_EVENT } from '../utils/auditEventNames.js';
|
|
46
|
+
import logger from '../utils/logger.js';
|
|
47
|
+
import { ADDON_SLUG, ledgerPrefix } from '../utils/addonDatabaseRules.js';
|
|
48
|
+
import { applyStagedRestore, manifestPath, runOffThread, stagedPath, writeManifest } from '../providers/sqliteBackup.js';
|
|
49
|
+
/** The environment variable holding the SQLCipher key. Never a config key: config is readable from the admin screens. */
|
|
50
|
+
export const DATABASE_KEY_ENV = 'NGDPBASE_DATABASE_KEY';
|
|
51
|
+
/**
|
|
52
|
+
* The environment variable holding the key every database backup is encrypted
|
|
53
|
+
* under (#1723). Its own secret, not the database key: the copy that travels
|
|
54
|
+
* and the copy that stays should not fall to the same compromise. Never a
|
|
55
|
+
* config key. Unset, no database is backed up, and admins are told.
|
|
56
|
+
*/
|
|
57
|
+
export const BACKUP_KEY_ENV = 'NGDPBASE_BACKUP_KEY';
|
|
58
|
+
/** How long a database backup or restore staging may run on its worker before it is stopped, when not configured. */
|
|
59
|
+
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
|
+
/** The backup's name for the application database; an add-on's is `addon-<owner>`. */
|
|
69
|
+
const APPLICATION = 'application';
|
|
70
|
+
const addonName = (owner) => `addon-${owner}`;
|
|
71
|
+
/**
|
|
72
|
+
* The request rules (addonDatabaseRules): the owner is a canonical slug, and
|
|
73
|
+
* the ledger table starts with its prefix. `openAddonDatabase` applies them,
|
|
74
|
+
* and so does the test kit's in-memory database
|
|
75
|
+
* (`@jwilleke/ngdpbase-types/testing`, #1865), so a test refuses what a site
|
|
76
|
+
* refuses.
|
|
77
|
+
*/
|
|
78
|
+
export function checkAddonDatabaseRequest(owner, options) {
|
|
79
|
+
if (!ADDON_SLUG.test(owner)) {
|
|
80
|
+
throw new Error(`'${owner}' is not an add-on slug — lowercase letters and digits, single dashes (ngdp-accounting-addons#13)`);
|
|
81
|
+
}
|
|
82
|
+
const prefix = ledgerPrefix(owner);
|
|
83
|
+
if (!options.ledgerTable.startsWith(prefix)) {
|
|
84
|
+
throw new Error(`Add-on '${owner}': migration ledger table '${options.ledgerTable}' must start with '${prefix}' (ngdp-accounting-addons#13)`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The declaration of this owner's ledger among `declarations`, or a refusal
|
|
89
|
+
* naming what is missing (#1860). `openAddonDatabase` asks it of the site's
|
|
90
|
+
* declarations; the test kit asks it of the add-on's own (#1865).
|
|
91
|
+
*/
|
|
92
|
+
export function addonDatabaseDeclarationFor(declarations, owner, ledgerTable) {
|
|
93
|
+
const forOwner = declarations.filter((d) => d.owner === owner);
|
|
94
|
+
const declaration = forOwner.find((d) => d.ledgerTable === ledgerTable);
|
|
95
|
+
if (!declaration) {
|
|
96
|
+
throw new Error(forOwner.length === 0
|
|
97
|
+
? `No add-on declares database '${owner}' — declare ngdpbase.addons.<slug>.database { owner, ledgerTable, encrypt } in the add-on's config/default-config.json (#1860)`
|
|
98
|
+
: `Database '${owner}' is declared, but not with ledger '${ledgerTable}' — the add-on that opens it declares that ledger (#1860)`);
|
|
99
|
+
}
|
|
100
|
+
if (!declaration.enabled) {
|
|
101
|
+
throw new Error(`Database '${owner}' ledger '${ledgerTable}' is declared by add-on '${declaration.declaredBy}', which is turned off or not loaded (#1860)`);
|
|
102
|
+
}
|
|
103
|
+
return declaration;
|
|
104
|
+
}
|
|
105
|
+
class DatabaseManager extends BaseManager {
|
|
106
|
+
provider = null;
|
|
107
|
+
databaseFile = null;
|
|
108
|
+
busyTimeoutMs = DEFAULT_BUSY_TIMEOUT_MS;
|
|
109
|
+
addonDatabases = new Map();
|
|
110
|
+
/** What the start did with a staged restore (#1723), reported once the notifications exist. */
|
|
111
|
+
restoreAtStart = null;
|
|
112
|
+
/** Why this manager is degraded, one entry per cause, so one cause clearing leaves the others. */
|
|
113
|
+
faults = new Map();
|
|
114
|
+
constructor(engine) {
|
|
115
|
+
super(engine);
|
|
116
|
+
}
|
|
117
|
+
async initialize(config = {}) {
|
|
118
|
+
await super.initialize(config);
|
|
119
|
+
const configManager = this.engine.getManager('ConfigurationManager');
|
|
120
|
+
const configured = configManager?.getProperty('ngdpbase.database.provider', 'none');
|
|
121
|
+
const kind = (typeof configured === 'string' ? configured : 'none').toLowerCase();
|
|
122
|
+
if (kind === 'none') {
|
|
123
|
+
logger.info('[DatabaseManager] No application database (ngdpbase.database.provider is none)');
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (kind !== 'sqlite' || !configManager) {
|
|
127
|
+
throw new Error(`ngdpbase.database.provider '${kind}' is not a database provider — use 'sqlite' or 'none' (#1536)`);
|
|
128
|
+
}
|
|
129
|
+
const busy = configManager.getProperty('ngdpbase.database.busy-timeout-ms', DEFAULT_BUSY_TIMEOUT_MS);
|
|
130
|
+
if (typeof busy !== 'number' || !Number.isInteger(busy) || busy < 0) {
|
|
131
|
+
throw new Error(`ngdpbase.database.busy-timeout-ms must be a whole number of milliseconds, 0 or more — got ${JSON.stringify(busy)} (#1710)`);
|
|
132
|
+
}
|
|
133
|
+
this.busyTimeoutMs = busy;
|
|
134
|
+
const file = configManager.getResolvedDataPath('ngdpbase.database.file', './data/ngdpbase.db');
|
|
135
|
+
const key = process.env[DATABASE_KEY_ENV] ?? '';
|
|
136
|
+
// #1723: a staged restore goes in now, before any database opens.
|
|
137
|
+
this.applyRestoreAtStart(file);
|
|
138
|
+
const provider = new SqliteDatabaseProvider(file, key, DATABASE_MIGRATIONS, 'schema_migrations', { busyTimeoutMs: this.busyTimeoutMs });
|
|
139
|
+
if (!provider.integrityOk()) {
|
|
140
|
+
provider.close();
|
|
141
|
+
throw new Error(`The application database ${file} failed its integrity check — refusing to start (#1536)`);
|
|
142
|
+
}
|
|
143
|
+
this.provider = provider;
|
|
144
|
+
this.databaseFile = file;
|
|
145
|
+
const { applied, skipped } = provider.migrations;
|
|
146
|
+
logger.info(`[DatabaseManager] Opened ${file} (${key ? 'encrypted' : 'NOT encrypted'}, lock wait ${this.busyTimeoutMs} ms) — migrations applied ${applied.length}, already present ${skipped}`);
|
|
147
|
+
if (!key) {
|
|
148
|
+
logger.warn(`[DatabaseManager] ${DATABASE_KEY_ENV} is not set, so the application database is stored unencrypted (#1536)`);
|
|
149
|
+
}
|
|
150
|
+
const keyProblem = this.backupKeyProblem();
|
|
151
|
+
if (keyProblem) {
|
|
152
|
+
logger.error(`[DatabaseManager] ${keyProblem}`);
|
|
153
|
+
this.fault('backup-key', keyProblem);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** Whether an application database is open. */
|
|
157
|
+
isEnabled() {
|
|
158
|
+
return this.provider !== null;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The open connection, for a row-storing provider's constructor. Throws when
|
|
162
|
+
* no database is configured, so a provider that needs one fails at boot with
|
|
163
|
+
* the key to set rather than at its first write.
|
|
164
|
+
*/
|
|
165
|
+
getHandle() {
|
|
166
|
+
if (!this.provider) {
|
|
167
|
+
throw new Error("No application database is open — set ngdpbase.database.provider to 'sqlite' (#1536)");
|
|
168
|
+
}
|
|
169
|
+
return this.provider.handle;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Open (or join) an add-on's own database and run its migrations
|
|
173
|
+
* (ngdp-accounting-addons#13). The file is `<owner>.db` beside the application
|
|
174
|
+
* database, opened by the same provider under the same key, so it gets the
|
|
175
|
+
* same network-filesystem refusal, WAL, integrity check and newer-schema
|
|
176
|
+
* refusal. The first call for an owner opens the file; a later call — from an
|
|
177
|
+
* add-on that depends on the owner — adds its own ledger to the same file.
|
|
178
|
+
*
|
|
179
|
+
* Requires the application database: an operator who has not set
|
|
180
|
+
* `ngdpbase.database.provider` to `sqlite` has not chosen to keep a database.
|
|
181
|
+
*
|
|
182
|
+
* Opens only a declared database (#1860): an enabled add-on declares this
|
|
183
|
+
* owner and this ledger in `ngdpbase.addons.<slug>.database`. A declaration
|
|
184
|
+
* with `encrypt: "required"` is refused when `NGDPBASE_DATABASE_KEY` is not
|
|
185
|
+
* set; nothing is opened in the clear.
|
|
186
|
+
*/
|
|
187
|
+
openAddonDatabase(owner, options) {
|
|
188
|
+
if (!this.provider || !this.databaseFile) {
|
|
189
|
+
throw new Error(`Add-on '${owner}' needs a database, but none is open — set ngdpbase.database.provider to 'sqlite' (ngdp-accounting-addons#13)`);
|
|
190
|
+
}
|
|
191
|
+
checkAddonDatabaseRequest(owner, options);
|
|
192
|
+
const declaration = addonDatabaseDeclarationFor(this.declarations(), owner, options.ledgerTable);
|
|
193
|
+
const key = process.env[DATABASE_KEY_ENV] ?? '';
|
|
194
|
+
const refusal = DatabaseManager.keyRefusal(declaration, key);
|
|
195
|
+
if (refusal)
|
|
196
|
+
throw new Error(refusal);
|
|
197
|
+
const existing = this.addonDatabases.get(owner);
|
|
198
|
+
if (existing) {
|
|
199
|
+
if (existing.ledgers.has(options.ledgerTable)) {
|
|
200
|
+
throw new Error(`Add-on '${owner}': migration ledger '${options.ledgerTable}' is already registered (ngdp-accounting-addons#13)`);
|
|
201
|
+
}
|
|
202
|
+
const { applied, skipped } = existing.provider.migrate(options.migrations, options.ledgerTable);
|
|
203
|
+
existing.ledgers.add(options.ledgerTable);
|
|
204
|
+
logger.info(`[DatabaseManager] ${owner}: ledger ${options.ledgerTable} — migrations applied ${applied.length}, already present ${skipped}`);
|
|
205
|
+
return existing.provider.handle;
|
|
206
|
+
}
|
|
207
|
+
const file = path.join(path.dirname(this.databaseFile), `${owner}.db`);
|
|
208
|
+
if (path.resolve(file) === path.resolve(this.databaseFile)) {
|
|
209
|
+
throw new Error(`Add-on '${owner}' would open the application database itself (${file}) (ngdp-accounting-addons#13)`);
|
|
210
|
+
}
|
|
211
|
+
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
|
+
this.addonDatabases.set(owner, { provider, ledgers: new Set([options.ledgerTable]) });
|
|
217
|
+
const { applied, skipped } = provider.migrations;
|
|
218
|
+
logger.info(`[DatabaseManager] Opened ${file} for ${owner} (${key ? 'encrypted' : 'NOT encrypted'}) — ledger ${options.ledgerTable}, migrations applied ${applied.length}, already present ${skipped}`);
|
|
219
|
+
return provider.handle;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Every add-on database declared on this site (#1860), whether or not its
|
|
223
|
+
* add-on is turned on — so a turned-off add-on's file is still listed and
|
|
224
|
+
* backed up (#1723). One entry per declared ledger; several may share an
|
|
225
|
+
* owner's file.
|
|
226
|
+
*/
|
|
227
|
+
getDeclaredDatabases() {
|
|
228
|
+
return this.declarations().map((d) => ({
|
|
229
|
+
...d,
|
|
230
|
+
file: this.databaseFile ? path.join(path.dirname(this.databaseFile), `${d.owner}.db`) : null,
|
|
231
|
+
open: this.addonDatabases.get(d.owner)?.ledgers.has(d.ledgerTable) ?? false
|
|
232
|
+
}));
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Why this add-on must not load, decided before it is imported (#1860): it
|
|
236
|
+
* declares a database `encrypt: "required"` and no key is set. Null when it
|
|
237
|
+
* may load. Asked by AddonsManager, as the configuration merge's refusals
|
|
238
|
+
* are (#1559).
|
|
239
|
+
*/
|
|
240
|
+
addonRefusal(slug) {
|
|
241
|
+
const key = process.env[DATABASE_KEY_ENV] ?? '';
|
|
242
|
+
for (const declaration of this.declarations()) {
|
|
243
|
+
if (declaration.declaredBy !== slug || !declaration.enabled)
|
|
244
|
+
continue;
|
|
245
|
+
const refusal = DatabaseManager.keyRefusal(declaration, key);
|
|
246
|
+
if (refusal)
|
|
247
|
+
return refusal;
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
declarations() {
|
|
252
|
+
return this.engine.getManager('ConfigurationManager')?.getAddonDatabaseDeclarations?.() ?? [];
|
|
253
|
+
}
|
|
254
|
+
/** The one rule: a database declared `encrypt: "required"` is never opened without the key. */
|
|
255
|
+
static keyRefusal(declaration, key) {
|
|
256
|
+
if (declaration.encrypt !== 'required' || key)
|
|
257
|
+
return null;
|
|
258
|
+
return `Add-on '${declaration.declaredBy}' declares database '${declaration.owner}' with encrypt "required", but ${DATABASE_KEY_ENV} is not set — it is never opened unencrypted (#1860)`;
|
|
259
|
+
}
|
|
260
|
+
// ---- Backup and restore (#1723) ------------------------------------------
|
|
261
|
+
/**
|
|
262
|
+
* Copy every database into the backup BackupManager is writing: the
|
|
263
|
+
* application database and every declared add-on database, on or off, at
|
|
264
|
+
* one point in time, on a worker thread. Each goes into its own file,
|
|
265
|
+
* `<stem>.<name>.db`, beside the document, encrypted under
|
|
266
|
+
* `NGDPBASE_BACKUP_KEY`; the document references them. A database that
|
|
267
|
+
* fails is named in `warnings`, and the others are still written.
|
|
268
|
+
*
|
|
269
|
+
* Throws when there is no backup key: nothing is ever written unencrypted.
|
|
270
|
+
*/
|
|
271
|
+
async backup(options) {
|
|
272
|
+
const base = { managerName: 'DatabaseManager', timestamp: new Date().toISOString() };
|
|
273
|
+
if (!this.provider || !this.databaseFile) {
|
|
274
|
+
return { ...base, data: null, note: 'No application database (ngdpbase.database.provider is none)' };
|
|
275
|
+
}
|
|
276
|
+
if (!options?.directory || !options.stem) {
|
|
277
|
+
throw new Error('DatabaseManager writes its databases beside the backup document, so it is backed up through BackupManager (#1723)');
|
|
278
|
+
}
|
|
279
|
+
const backupKey = this.backupKey();
|
|
280
|
+
if (!backupKey) {
|
|
281
|
+
const reason = `${BACKUP_KEY_ENV} is not set, so no database was backed up. Database backups are always encrypted and are never written without it (#1723)`;
|
|
282
|
+
this.fault('backup-key', reason);
|
|
283
|
+
throw new Error(reason);
|
|
284
|
+
}
|
|
285
|
+
const key = process.env[DATABASE_KEY_ENV] ?? '';
|
|
286
|
+
const keyProblem = this.backupKeyProblem();
|
|
287
|
+
if (keyProblem) {
|
|
288
|
+
// D3: still backed up, but as loudly as a missing key.
|
|
289
|
+
logger.error(`[DatabaseManager] ${keyProblem}`);
|
|
290
|
+
this.fault('backup-key', keyProblem);
|
|
291
|
+
await this.notify('error', 'The backup key is the database key', `${keyProblem}. This backup was still taken.`);
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
this.clearFault('backup-key');
|
|
295
|
+
}
|
|
296
|
+
const sources = this.backupSources(this.databaseFile);
|
|
297
|
+
const absent = sources.filter((s) => !existsSync(s.file)).map((s) => s.name);
|
|
298
|
+
const exports = sources
|
|
299
|
+
.filter((s) => existsSync(s.file))
|
|
300
|
+
.map((s) => ({ name: s.name, source: s.file, sourceKey: key, target: path.join(options.directory, `${options.stem}.${s.name}.db`) }));
|
|
301
|
+
let outcome;
|
|
302
|
+
try {
|
|
303
|
+
outcome = await runOffThread({ kind: 'export', exports, backupKey }, this.workerTimeoutMs());
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
for (const e of exports)
|
|
307
|
+
rmSync(e.target, { force: true });
|
|
308
|
+
throw err;
|
|
309
|
+
}
|
|
310
|
+
const byName = new Map(sources.map((s) => [s.name, s]));
|
|
311
|
+
const data = {
|
|
312
|
+
format: 1,
|
|
313
|
+
keyFingerprint: backupKeyFingerprint(backupKey),
|
|
314
|
+
databases: outcome.written.map((w) => {
|
|
315
|
+
const s = byName.get(w.name);
|
|
316
|
+
return { name: w.name, kind: s.kind, ...(s.owner ? { owner: s.owner } : {}), file: path.basename(w.target), sizeBytes: w.sizeBytes, tables: w.tables };
|
|
317
|
+
}),
|
|
318
|
+
absent,
|
|
319
|
+
failed: outcome.failed
|
|
320
|
+
};
|
|
321
|
+
logger.info(`[DatabaseManager] Backed up ${data.databases.length} database(s): ${data.databases.map((d) => d.name).join(', ')}${absent.length ? `; not created yet: ${absent.join(', ')}` : ''} (#1723)`);
|
|
322
|
+
const warnings = outcome.failed.map((f) => `database ${f.name} was not backed up: ${f.error}`);
|
|
323
|
+
for (const w of warnings)
|
|
324
|
+
logger.error(`[DatabaseManager] ${w}`);
|
|
325
|
+
return { ...base, data, ...(warnings.length > 0 ? { warnings } : {}) };
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Stage a restore of the databases in a backup, for the next start. Each
|
|
329
|
+
* backed-up database is opened under the backup key, integrity-checked, and
|
|
330
|
+
* copied into `<live>.staged` under this site's database key. All or
|
|
331
|
+
* nothing: if any fails, none is staged. A database the backup does not
|
|
332
|
+
* contain is left as it is. Live files are never touched here.
|
|
333
|
+
*
|
|
334
|
+
* BackupManager takes a fresh backup of the live data before calling this.
|
|
335
|
+
* A backed-up add-on database that no add-on on this site declares is not
|
|
336
|
+
* staged, and admins are told.
|
|
337
|
+
*/
|
|
338
|
+
async restore(backupData, options) {
|
|
339
|
+
await super.restore(backupData);
|
|
340
|
+
const data = backupData.data;
|
|
341
|
+
if (!data || !Array.isArray(data.databases) || data.databases.length === 0) {
|
|
342
|
+
logger.info('[DatabaseManager] The backup holds no database; every database is left as it is (#1723)');
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (!this.provider || !this.databaseFile) {
|
|
346
|
+
throw new Error("The backup holds databases, but this site has none open — set ngdpbase.database.provider to 'sqlite' first (#1723)");
|
|
347
|
+
}
|
|
348
|
+
if (!options?.directory) {
|
|
349
|
+
throw new Error('DatabaseManager reads its databases from beside the backup document, so it is restored through BackupManager (#1723)');
|
|
350
|
+
}
|
|
351
|
+
const backupKey = this.backupKey();
|
|
352
|
+
if (!backupKey) {
|
|
353
|
+
throw new Error(`${BACKUP_KEY_ENV} is not set, so the databases in this backup cannot be read; nothing was staged (#1723)`);
|
|
354
|
+
}
|
|
355
|
+
if (data.keyFingerprint && data.keyFingerprint !== backupKeyFingerprint(backupKey)) {
|
|
356
|
+
throw new Error(`This backup was made with a different backup key (fingerprint ${data.keyFingerprint}; ${BACKUP_KEY_ENV} now has ${backupKeyFingerprint(backupKey)}). Set ${BACKUP_KEY_ENV} to the key it was made with; nothing was staged (#1723)`);
|
|
357
|
+
}
|
|
358
|
+
const staged = this.getStagedRestore();
|
|
359
|
+
if (staged && !options.replaceStaged) {
|
|
360
|
+
throw new Error(`A restore from ${staged.source || staged.backup} is already staged (by ${staged.stagedBy || 'unknown'}, ${staged.stagedAt}). Cancel it, or replace it; nothing was changed (#1723)`);
|
|
361
|
+
}
|
|
362
|
+
const targetKey = process.env[DATABASE_KEY_ENV] ?? '';
|
|
363
|
+
const dir = path.dirname(this.databaseFile);
|
|
364
|
+
const declarations = this.declarations();
|
|
365
|
+
const stages = [];
|
|
366
|
+
const skipped = [];
|
|
367
|
+
for (const entry of data.databases) {
|
|
368
|
+
if (typeof entry?.file !== 'string' || path.basename(entry.file) !== entry.file || entry.file.startsWith('.')) {
|
|
369
|
+
throw new Error(`The backup names a database file '${String(entry?.file)}' outside its directory; nothing was staged (#1723)`);
|
|
370
|
+
}
|
|
371
|
+
let live;
|
|
372
|
+
if (entry.name === APPLICATION) {
|
|
373
|
+
live = this.databaseFile;
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
const owner = entry.name.startsWith('addon-') ? entry.name.slice('addon-'.length) : '';
|
|
377
|
+
if (!ADDON_SLUG.test(owner)) {
|
|
378
|
+
throw new Error(`The backup holds a database '${entry.name}' this build does not know; nothing was staged (#1723)`);
|
|
379
|
+
}
|
|
380
|
+
const declared = declarations.filter((d) => d.owner === owner);
|
|
381
|
+
if (declared.length === 0) {
|
|
382
|
+
skipped.push(entry.name);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
if (!targetKey && declared.some((d) => d.encrypt === 'required')) {
|
|
386
|
+
throw new Error(`Database '${owner}' is declared encrypt "required", but ${DATABASE_KEY_ENV} is not set — it is never restored unencrypted; nothing was staged (#1723)`);
|
|
387
|
+
}
|
|
388
|
+
live = path.join(dir, `${owner}.db`);
|
|
389
|
+
}
|
|
390
|
+
const backupFile = path.join(options.directory, entry.file);
|
|
391
|
+
if (!existsSync(backupFile)) {
|
|
392
|
+
throw new Error(`The backup's database file ${entry.file} is missing from ${options.directory}; nothing was staged (#1723)`);
|
|
393
|
+
}
|
|
394
|
+
stages.push({ name: entry.name, backupFile, live, targetKey });
|
|
395
|
+
}
|
|
396
|
+
if (stages.length === 0) {
|
|
397
|
+
throw new Error(`None of the databases in this backup belongs to this site (${skipped.join(', ')}); nothing was staged (#1723)`);
|
|
398
|
+
}
|
|
399
|
+
// A restore staged earlier and not yet applied is replaced by this one.
|
|
400
|
+
const manifestFile = manifestPath(this.databaseFile);
|
|
401
|
+
for (const previous of this.getStagedRestore()?.databases ?? [])
|
|
402
|
+
rmSync(stagedPath(previous.live), { force: true });
|
|
403
|
+
rmSync(manifestFile, { force: true });
|
|
404
|
+
try {
|
|
405
|
+
await runOffThread({ kind: 'stage', stages, backupKey }, this.workerTimeoutMs());
|
|
406
|
+
}
|
|
407
|
+
catch (err) {
|
|
408
|
+
for (const s of stages)
|
|
409
|
+
rmSync(stagedPath(s.live), { force: true });
|
|
410
|
+
throw new Error(`The databases were not staged, and nothing was changed: ${err.message}`, { cause: err });
|
|
411
|
+
}
|
|
412
|
+
const manifest = {
|
|
413
|
+
version: 1,
|
|
414
|
+
stagedAt: new Date().toISOString(),
|
|
415
|
+
backup: String(backupData.timestamp ?? ''),
|
|
416
|
+
source: options.source ?? '',
|
|
417
|
+
stagedBy: actorOf(options.ctx).user,
|
|
418
|
+
...(options.safetyBackup ? { safetyBackup: options.safetyBackup } : {}),
|
|
419
|
+
databases: stages.map((s) => ({ name: s.name, live: s.live, staged: stagedPath(s.live) }))
|
|
420
|
+
};
|
|
421
|
+
writeManifest(manifestFile, manifest);
|
|
422
|
+
const names = stages.map((s) => s.name).join(', ');
|
|
423
|
+
logger.warn(`[DatabaseManager] Restore staged for ${names} — restart to apply it (#1723)`);
|
|
424
|
+
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}.` : ''}`);
|
|
425
|
+
if (skipped.length > 0) {
|
|
426
|
+
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
|
+
logger.error(`[DatabaseManager] ${message}`);
|
|
428
|
+
await this.notify('error', 'Some databases in the backup were not restored', message);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
/** The restore staged and waiting for the next start, or null. */
|
|
432
|
+
getStagedRestore() {
|
|
433
|
+
if (!this.databaseFile)
|
|
434
|
+
return null;
|
|
435
|
+
const file = manifestPath(this.databaseFile);
|
|
436
|
+
if (!existsSync(file))
|
|
437
|
+
return null;
|
|
438
|
+
try {
|
|
439
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Throw away a staged restore before the restart applies it (#1723, D5):
|
|
447
|
+
* the staged files and the manifest are removed, the live databases are
|
|
448
|
+
* untouched. Recorded first (`backup-restore-cancel`, refuse): a cancel that
|
|
449
|
+
* cannot be recorded does not happen.
|
|
450
|
+
*/
|
|
451
|
+
async cancelStagedRestore(ctx) {
|
|
452
|
+
const staged = this.getStagedRestore();
|
|
453
|
+
if (!staged || !this.databaseFile)
|
|
454
|
+
throw new Error('No database restore is staged');
|
|
455
|
+
const who = actorOf(ctx);
|
|
456
|
+
await recordAuditEvent(this.engine.getManager('AuditManager'), {
|
|
457
|
+
eventType: AUDIT_EVENT.BACKUP_RESTORE_CANCEL,
|
|
458
|
+
...who,
|
|
459
|
+
action: 'backup-restore-cancel',
|
|
460
|
+
result: 'success',
|
|
461
|
+
severity: 'high',
|
|
462
|
+
resource: staged.source || staged.backup,
|
|
463
|
+
resourceType: 'backup',
|
|
464
|
+
metadata: { ...who.metadata, stagedAt: staged.stagedAt, stagedBy: staged.stagedBy, databases: staged.databases.map((d) => d.name) }
|
|
465
|
+
});
|
|
466
|
+
for (const d of staged.databases)
|
|
467
|
+
rmSync(stagedPath(d.live), { force: true });
|
|
468
|
+
rmSync(manifestPath(this.databaseFile), { force: true });
|
|
469
|
+
logger.warn(`[DatabaseManager] Staged restore from ${staged.source || staged.backup} cancelled by ${who.user} (#1723)`);
|
|
470
|
+
return staged;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Tell admins what the start found (#1723): what it did with a staged
|
|
474
|
+
* restore, and a backup key that is missing or the database key. Called by
|
|
475
|
+
* the engine once every manager exists: DatabaseManager starts before the
|
|
476
|
+
* notifications do.
|
|
477
|
+
*/
|
|
478
|
+
async reportAtBoot() {
|
|
479
|
+
const keyProblem = this.provider ? this.backupKeyProblem() : null;
|
|
480
|
+
if (keyProblem)
|
|
481
|
+
await this.notify('error', 'Databases are not safely backed up', keyProblem);
|
|
482
|
+
const outcome = this.restoreAtStart;
|
|
483
|
+
this.restoreAtStart = null;
|
|
484
|
+
if (!outcome)
|
|
485
|
+
return;
|
|
486
|
+
if (outcome.refused) {
|
|
487
|
+
await this.notify('error', 'A database restore was not applied', `${outcome.refused}. The site started on its current databases.`);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
await this.notify('success', 'A database restore was applied', `Restored at start: ${outcome.applied.map((a) => a.name).join(', ')}. The previous files are kept as *.pre-restore.`);
|
|
491
|
+
}
|
|
492
|
+
/** The application database and every declared add-on database (one per owner), on or off. */
|
|
493
|
+
backupSources(applicationFile) {
|
|
494
|
+
const sources = [{ name: APPLICATION, kind: 'application', file: applicationFile }];
|
|
495
|
+
for (const owner of new Set(this.declarations().map((d) => d.owner))) {
|
|
496
|
+
sources.push({ name: addonName(owner), kind: 'addon', owner, file: path.join(path.dirname(applicationFile), `${owner}.db`) });
|
|
497
|
+
}
|
|
498
|
+
return sources;
|
|
499
|
+
}
|
|
500
|
+
/** Put a staged restore in place, before any database opens; loud when it cannot. */
|
|
501
|
+
applyRestoreAtStart(file) {
|
|
502
|
+
const dir = path.resolve(path.dirname(file));
|
|
503
|
+
const allowed = (live) => {
|
|
504
|
+
const resolved = path.resolve(live);
|
|
505
|
+
if (resolved === path.resolve(file))
|
|
506
|
+
return true;
|
|
507
|
+
const name = path.basename(resolved);
|
|
508
|
+
return path.dirname(resolved) === dir && name.endsWith('.db') && ADDON_SLUG.test(name.slice(0, -3));
|
|
509
|
+
};
|
|
510
|
+
const outcome = applyStagedRestore(file, allowed);
|
|
511
|
+
if (!outcome)
|
|
512
|
+
return;
|
|
513
|
+
this.restoreAtStart = outcome;
|
|
514
|
+
if (outcome.refused) {
|
|
515
|
+
const reason = `A staged database restore was NOT applied: ${outcome.refused}. The site started on its current databases (#1723)`;
|
|
516
|
+
logger.error(`[DatabaseManager] ${reason}`);
|
|
517
|
+
this.fault('restore', reason);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
for (const a of outcome.applied) {
|
|
521
|
+
logger.warn(`[DatabaseManager] Restore applied: ${a.name} -> ${a.live} (previous kept as ${path.basename(a.kept)}) (#1723)`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
backupKey() {
|
|
525
|
+
return (process.env[BACKUP_KEY_ENV] ?? '').trim();
|
|
526
|
+
}
|
|
527
|
+
/** What is wrong with the backup key: missing, or the database key itself (D3). Null when sound. */
|
|
528
|
+
backupKeyProblem() {
|
|
529
|
+
const backupKey = this.backupKey();
|
|
530
|
+
if (!backupKey)
|
|
531
|
+
return `${BACKUP_KEY_ENV} is not set, so no database is backed up — set it in the environment or the instance .env (#1723)`;
|
|
532
|
+
const key = process.env[DATABASE_KEY_ENV] ?? '';
|
|
533
|
+
if (key && key.trim() === backupKey) {
|
|
534
|
+
return `${BACKUP_KEY_ENV} is the same as ${DATABASE_KEY_ENV}: a stolen backup and a stolen data disk then fall to one secret — give the backups a key of their own (#1723)`;
|
|
535
|
+
}
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
/** `ngdpbase.database.backup.worker-timeout-ms`, read at use. */
|
|
539
|
+
workerTimeoutMs() {
|
|
540
|
+
const configured = this.engine.getManager('ConfigurationManager')
|
|
541
|
+
?.getProperty('ngdpbase.database.backup.worker-timeout-ms', DEFAULT_BACKUP_WORKER_TIMEOUT_MS) ?? DEFAULT_BACKUP_WORKER_TIMEOUT_MS;
|
|
542
|
+
if (typeof configured !== 'number' || !Number.isInteger(configured) || configured <= 0) {
|
|
543
|
+
throw new Error(`ngdpbase.database.backup.worker-timeout-ms must be a whole number of milliseconds, more than 0 — got ${JSON.stringify(configured)} (#1723)`);
|
|
544
|
+
}
|
|
545
|
+
return configured;
|
|
546
|
+
}
|
|
547
|
+
/** Degrade for one cause; the dashboard shows every cause still standing. */
|
|
548
|
+
fault(cause, reason) {
|
|
549
|
+
this.faults.set(cause, reason);
|
|
550
|
+
this.markDegraded([...this.faults.values()].join('; '));
|
|
551
|
+
}
|
|
552
|
+
clearFault(cause) {
|
|
553
|
+
if (!this.faults.delete(cause))
|
|
554
|
+
return;
|
|
555
|
+
if (this.faults.size === 0)
|
|
556
|
+
this.markReady();
|
|
557
|
+
else
|
|
558
|
+
this.markDegraded([...this.faults.values()].join('; '));
|
|
559
|
+
}
|
|
560
|
+
async notify(level, title, message) {
|
|
561
|
+
try {
|
|
562
|
+
await this.engine.getManager('NotificationManager')
|
|
563
|
+
?.addNotification({ type: 'system', level, title, message });
|
|
564
|
+
}
|
|
565
|
+
catch (err) {
|
|
566
|
+
logger.error(`[DatabaseManager] The notification '${title}' could not be sent (#1723):`, err);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
/** Whether every open database passes SQLite's quick check; true when none is configured. */
|
|
570
|
+
integrityOk() {
|
|
571
|
+
if (this.provider && !this.provider.integrityOk())
|
|
572
|
+
return false;
|
|
573
|
+
for (const { provider } of this.addonDatabases.values()) {
|
|
574
|
+
if (!provider.integrityOk())
|
|
575
|
+
return false;
|
|
576
|
+
}
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
/** Closes the add-on databases, then the application database. */
|
|
580
|
+
async shutdown() {
|
|
581
|
+
for (const { provider } of this.addonDatabases.values())
|
|
582
|
+
provider.close();
|
|
583
|
+
this.addonDatabases.clear();
|
|
584
|
+
this.provider?.close();
|
|
585
|
+
this.provider = null;
|
|
586
|
+
this.databaseFile = null;
|
|
587
|
+
await super.shutdown();
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
export default DatabaseManager;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EmailManager — shared outbound email transport.
|
|
3
|
+
*
|
|
4
|
+
* A single manager used by any feature that needs to send email:
|
|
5
|
+
* magic-link auth, notification escalation, server-error alerts, etc.
|
|
6
|
+
*
|
|
7
|
+
* Two providers, selected by `ngdpbase.mail.provider`:
|
|
8
|
+
* console — prints to server log (default / development)
|
|
9
|
+
* smtp — sends via NodemailerMailProvider; works with any SMTP relay
|
|
10
|
+
* (Gmail, Resend, SendGrid, SES, self-hosted Postfix, …)
|
|
11
|
+
*
|
|
12
|
+
* Implements the MailProvider interface so it can be passed directly to
|
|
13
|
+
* MagicLinkAuthProvider without any changes to that class.
|
|
14
|
+
*
|
|
15
|
+
* @see docs/admin/email-setup.md for operator setup instructions
|
|
16
|
+
* @see {@link https://github.com/jwilleke/ngdpbase/issues/456}
|
|
17
|
+
*/
|
|
18
|
+
import BaseManager from './BaseManager.js';
|
|
19
|
+
import type { BackupData } from './BaseManager.js';
|
|
20
|
+
import type { WikiEngine } from '../types/WikiEngine.js';
|
|
21
|
+
import type { MailMessage, MailProvider } from '../mail/MailProvider.js';
|
|
22
|
+
declare class EmailManager extends BaseManager implements MailProvider {
|
|
23
|
+
private provider;
|
|
24
|
+
private providerName;
|
|
25
|
+
private from;
|
|
26
|
+
private enabled;
|
|
27
|
+
constructor(engine: WikiEngine);
|
|
28
|
+
initialize(config?: Record<string, unknown>): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Send an email. Injects the configured from address if the message omits it.
|
|
31
|
+
*/
|
|
32
|
+
send(message: MailMessage): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Convenience wrapper — build and send a message in one call.
|
|
35
|
+
*/
|
|
36
|
+
sendTo(to: string, subject: string, text: string, html?: string): Promise<void>;
|
|
37
|
+
/** Name of the active provider: 'console' | 'smtp' */
|
|
38
|
+
getProviderName(): string;
|
|
39
|
+
/** Effective from address (smtp.from or mail.from) */
|
|
40
|
+
getFrom(): string;
|
|
41
|
+
/** Whether ngdpbase.mail.enabled is true */
|
|
42
|
+
isEnabled(): boolean;
|
|
43
|
+
shutdown(): Promise<void>;
|
|
44
|
+
backup(): Promise<BackupData>;
|
|
45
|
+
restore(_backupData: BackupData): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
export default EmailManager;
|