@jwilleke/ngdpbase-types 5.25.0 → 5.26.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.
Files changed (59) hide show
  1. package/README.md +1 -1
  2. package/lib/packages/ngdpbase-types/index.d.ts +4 -0
  3. package/lib/src/http/egressPolicy.js +61 -0
  4. package/lib/src/http/guardedElasticsearch.d.ts +27 -0
  5. package/lib/src/http/guardedElasticsearch.js +99 -0
  6. package/lib/src/managers/AddonsManager.d.ts +37 -1
  7. package/lib/src/managers/AttachmentManager.d.ts +4 -5
  8. package/lib/src/managers/AuthManager.d.ts +150 -32
  9. package/lib/src/managers/BackgroundJobManager.d.ts +22 -12
  10. package/lib/src/managers/BackupManager.d.ts +779 -0
  11. package/lib/src/managers/BackupManager.js +2042 -0
  12. package/lib/src/managers/BaseManager.d.ts +156 -6
  13. package/lib/src/managers/BaseManager.js +64 -2
  14. package/lib/src/managers/CommentManager.d.ts +6 -6
  15. package/lib/src/managers/ConsentManager.d.ts +130 -0
  16. package/lib/src/managers/DatabaseManager.d.ts +6 -0
  17. package/lib/src/managers/DatabaseManager.js +25 -15
  18. package/lib/src/managers/FootnoteManager.d.ts +6 -6
  19. package/lib/src/managers/OrganizationManager.d.ts +8 -6
  20. package/lib/src/managers/PageManager.d.ts +16 -0
  21. package/lib/src/managers/PersonManager.d.ts +8 -6
  22. package/lib/src/managers/RoleManager.d.ts +6 -8
  23. package/lib/src/providers/BaseAttachmentProvider.d.ts +4 -4
  24. package/lib/src/providers/BaseBackupProvider.d.ts +77 -0
  25. package/lib/src/providers/BaseBackupProvider.js +52 -0
  26. package/lib/src/providers/BasicAttachmentProvider.d.ts +4 -4
  27. package/lib/src/providers/FileBackupProvider.d.ts +34 -0
  28. package/lib/src/providers/FileBackupProvider.js +116 -0
  29. package/lib/src/providers/FileConsentProvider.d.ts +18 -0
  30. package/lib/src/providers/SqliteDatabaseProvider.d.ts +2 -2
  31. package/lib/src/providers/SqliteDatabaseProvider.js +12 -11
  32. package/lib/src/providers/attachmentFilesBackup.d.ts +5 -4
  33. package/lib/src/providers/databaseMigrations.js +45 -0
  34. package/lib/src/security/PolicyDecisionPoint.d.ts +14 -0
  35. package/lib/src/types/Consent.d.ts +90 -0
  36. package/lib/src/types/express.d.ts +6 -0
  37. package/lib/src/utils/addonHost.d.ts +22 -0
  38. package/lib/src/utils/addonHost.js +14 -1
  39. package/lib/src/utils/auditEventNames.generated.d.ts +20 -0
  40. package/lib/src/utils/auditEventNames.generated.js +20 -0
  41. package/lib/src/utils/backupCrypto.js +275 -0
  42. package/lib/src/utils/backupDocument.d.ts +36 -0
  43. package/lib/src/utils/backupDocument.js +99 -0
  44. package/lib/src/utils/browserDescription.d.ts +11 -0
  45. package/lib/src/utils/directoryBackup.d.ts +48 -48
  46. package/lib/src/utils/directoryBackup.js +291 -0
  47. package/lib/src/utils/instanceEnvBackup.d.ts +46 -0
  48. package/lib/src/utils/instanceEnvBackup.js +120 -0
  49. package/lib/src/utils/instanceEnvSecret.js +79 -0
  50. package/lib/src/utils/ipLocation.d.ts +11 -0
  51. package/lib/src/utils/schedule.js +171 -0
  52. package/lib/src/utils/secretFileMode.js +78 -0
  53. package/lib/src/utils/siteRestore.d.ts +13 -16
  54. package/lib/src/utils/siteRestore.js +33 -6
  55. package/lib/src/utils/treeArchive.d.ts +2 -0
  56. package/lib/src/utils/treeArchive.js +242 -0
  57. package/lib/src/utils/version.js +408 -0
  58. package/package.json +6 -1
  59. package/lib/src/utils/administrators.d.ts +0 -21
@@ -0,0 +1,11 @@
1
+ export interface IpLocationSources {
2
+ /** The site is reached only through Cloudflare, so its location headers can be believed. */
3
+ behindCloudflare: boolean;
4
+ /** Path to a GeoLite2 City `.mmdb` file, or '' for none. */
5
+ geoliteFile: string;
6
+ }
7
+ /**
8
+ * Roughly where `ip` is: Cloudflare's headers when the site is behind it and
9
+ * they name a place, else the GeoLite2 file when one is configured, else null.
10
+ */
11
+ export declare function approximateLocation(ip: string | undefined, header: (name: string) => string | undefined, sources: IpLocationSources): Promise<string | null>;
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The schedule engine for scheduled jobs (#1713, epic #1611).
3
+ *
4
+ * A schedule is an iCalendar recurrence rule (RFC 5545 RRULE), the format the
5
+ * calendar add-on already stores, with a start (`dtstart`) and a time zone.
6
+ * Cron is not used: it cannot say "every 30 days" or "the last business day".
7
+ * Named shorthands (`daily 06:00`, `end-of-month`, …) expand to an RRULE.
8
+ *
9
+ * Recurrence is computed on wall-clock time and then placed in the time zone,
10
+ * so daylight saving does what a person expects:
11
+ * - spring forward: a slot inside the skipped hour runs at the next valid
12
+ * instant (02:30 becomes 03:30), rather than not at all;
13
+ * - fall back: a slot inside the repeated hour runs once, at its first
14
+ * occurrence.
15
+ * A date that does not exist is skipped, as RFC 5545 says: `BYMONTHDAY=31`
16
+ * skips short months; the last day of a month is `BYMONTHDAY=-1`. RFC 7529
17
+ * (RSCALE / SKIP) is not used (operator, 2026-10-08).
18
+ *
19
+ * Pure: no configuration is read here. The caller passes the default time zone
20
+ * (`ngdpbase.default.timezone`) and the minimum interval.
21
+ *
22
+ * @module utils/schedule
23
+ */
24
+ import { RRuleTemporal } from 'rrule-temporal';
25
+ import { Temporal } from 'temporal-polyfill';
26
+ /**
27
+ * The default start. Rules that count an interval (`every 30 days`) are
28
+ * counted from it, so their phase is the same on every restart.
29
+ */
30
+ const DEFAULT_DTSTART = '2000-01-01T00:00:00';
31
+ const TIME = String.raw `(?:\s+(?:at\s+)?(\d{1,2}):(\d{2}))?`;
32
+ const DAYS = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'];
33
+ /** Hour and minute parts for an optional `HH:MM`; midnight when absent. */
34
+ function at(hour, minute) {
35
+ const h = hour === undefined ? 0 : Number(hour);
36
+ const m = minute === undefined ? 0 : Number(minute);
37
+ if (h > 23 || m > 59)
38
+ throw new Error(`Invalid time ${hour}:${minute}`);
39
+ return `BYHOUR=${h};BYMINUTE=${m}`;
40
+ }
41
+ /**
42
+ * The shorthands, each expanding to one RRULE. Case-insensitive; a time is
43
+ * optional where shown and defaults to 00:00.
44
+ */
45
+ const SHORTHANDS = [
46
+ [/^hourly$/i, () => 'FREQ=HOURLY;BYMINUTE=0'],
47
+ [new RegExp(`^daily${TIME}$`, 'i'), (m) => `FREQ=DAILY;${at(m[1], m[2])}`],
48
+ [new RegExp(`^weekly\\s+(mo|tu|we|th|fr|sa|su)${TIME}$`, 'i'), (m) => `FREQ=WEEKLY;BYDAY=${m[1].toUpperCase()};${at(m[2], m[3])}`],
49
+ [/^every\s+(\d+)\s*m(?:in(?:utes?)?)?$/i, (m) => `FREQ=MINUTELY;INTERVAL=${Number(m[1])}`],
50
+ [/^every\s+(\d+)\s*h(?:ours?)?$/i, (m) => `FREQ=HOURLY;INTERVAL=${Number(m[1])};BYMINUTE=0`],
51
+ [new RegExp(`^every\\s+(\\d+)\\s*d(?:ays?)?${TIME}$`, 'i'), (m) => `FREQ=DAILY;INTERVAL=${Number(m[1])};${at(m[2], m[3])}`],
52
+ [new RegExp(`^start-of-month${TIME}$`, 'i'), (m) => `FREQ=MONTHLY;BYMONTHDAY=1;${at(m[1], m[2])}`],
53
+ [new RegExp(`^end-of-month${TIME}$`, 'i'), (m) => `FREQ=MONTHLY;BYMONTHDAY=-1;${at(m[1], m[2])}`],
54
+ [new RegExp(`^last-business-day${TIME}$`, 'i'), (m) => `FREQ=MONTHLY;BYDAY=${DAYS.slice(0, 5).join(',')};BYSETPOS=-1;${at(m[1], m[2])}`],
55
+ [new RegExp(`^end-of-quarter${TIME}$`, 'i'), (m) => `FREQ=MONTHLY;BYMONTH=3,6,9,12;BYMONTHDAY=-1;${at(m[1], m[2])}`],
56
+ [new RegExp(`^start-of-year${TIME}$`, 'i'), (m) => `FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1;${at(m[1], m[2])}`]
57
+ ];
58
+ /** A shorthand's RRULE, a bare RRULE as given, or an error naming what was not understood. */
59
+ export function expandShorthand(text) {
60
+ const trimmed = text.trim();
61
+ const bare = trimmed.replace(/^RRULE:/i, '');
62
+ if (/(?:^|;)FREQ=/i.test(bare))
63
+ return bare;
64
+ for (const [pattern, build] of SHORTHANDS) {
65
+ const m = pattern.exec(trimmed);
66
+ if (m)
67
+ return build(m);
68
+ }
69
+ throw new Error(`Unknown schedule "${text}": use an RRULE (FREQ=…) or one of hourly, daily HH:MM, weekly MO HH:MM, every Nm / Nh / Nd, start-of-month, end-of-month, last-business-day, end-of-quarter, start-of-year`);
70
+ }
71
+ /** The same wall-clock reading, as an instant in UTC: the frame recurrence is computed in. */
72
+ function toWallUtc(instant, timeZone) {
73
+ return Temporal.Instant.fromEpochMilliseconds(instant.getTime())
74
+ .toZonedDateTimeISO(timeZone)
75
+ .toPlainDateTime()
76
+ .toZonedDateTime('UTC');
77
+ }
78
+ /** A wall-clock reading placed in the time zone; a skipped hour moves forward, a repeated one takes its first. */
79
+ function fromWallUtc(wall, timeZone) {
80
+ const plain = Temporal.PlainDateTime.from(wall.toString().replace(/[+-]\d{2}:\d{2}\[UTC\]$|Z?\[UTC\]$/, ''));
81
+ return new Date(plain.toZonedDateTime(timeZone, { disambiguation: 'compatible' }).epochMilliseconds);
82
+ }
83
+ /**
84
+ * Compile a schedule. Throws, with a message a job author can act on, for an
85
+ * unknown shorthand, an invalid rule, an unknown time zone, `FREQ=SECONDLY`,
86
+ * an `RSCALE` other than GREGORIAN, or slots closer than the minimum interval.
87
+ */
88
+ export function parseSchedule(input, options) {
89
+ const spec = typeof input === 'string' ? { rrule: input } : input;
90
+ const rrule = expandShorthand(spec.rrule);
91
+ const timeZone = spec.tz ?? options.defaultTimeZone;
92
+ const dtstart = spec.dtstart ?? DEFAULT_DTSTART;
93
+ const minIntervalMs = options.minIntervalMs ?? 60_000;
94
+ if (/(^|;)FREQ=SECONDLY(;|$)/i.test(rrule))
95
+ throw new Error(`Schedule "${spec.rrule}": FREQ=SECONDLY is not allowed`);
96
+ const rscale = /(?:^|;)RSCALE=([^;]+)/i.exec(rrule);
97
+ if (rscale && rscale[1].toUpperCase() !== 'GREGORIAN')
98
+ throw new Error(`Schedule "${spec.rrule}": RSCALE=${rscale[1]} is not supported`);
99
+ try {
100
+ Temporal.Now.instant().toZonedDateTimeISO(timeZone);
101
+ }
102
+ catch (err) {
103
+ throw new Error(`Schedule "${spec.rrule}": unknown time zone "${timeZone}"`, { cause: err });
104
+ }
105
+ let start;
106
+ try {
107
+ start = Temporal.PlainDateTime.from(dtstart).toZonedDateTime('UTC');
108
+ }
109
+ catch (err) {
110
+ throw new Error(`Schedule "${spec.rrule}": dtstart "${dtstart}" is not a wall-clock date-time (YYYY-MM-DDTHH:MM:SS)`, { cause: err });
111
+ }
112
+ let rule;
113
+ try {
114
+ rule = new RRuleTemporal({ rruleString: rrule, dtstart: start });
115
+ }
116
+ catch (err) {
117
+ throw new Error(`Schedule "${spec.rrule}": ${err.message}`, { cause: err });
118
+ }
119
+ const schedule = {
120
+ rrule,
121
+ dtstart,
122
+ timeZone,
123
+ nextSlot(after) {
124
+ // Ask in the wall-clock frame, then place the answer in the zone. A slot
125
+ // that lands at or before `after` once placed (the repeated hour) is
126
+ // passed over.
127
+ let from = toWallUtc(after, timeZone);
128
+ for (let guard = 0; guard < 4; guard++) {
129
+ const next = rule.next(from, false);
130
+ if (!next)
131
+ return null;
132
+ const slot = fromWallUtc(next, timeZone);
133
+ if (slot.getTime() > after.getTime())
134
+ return slot;
135
+ from = Temporal.ZonedDateTime.from(next.toString());
136
+ }
137
+ return null;
138
+ },
139
+ slotsBetween(from, to) {
140
+ if (to.getTime() < from.getTime())
141
+ return [];
142
+ // Widen by a day so slots moved across the edge by daylight saving are
143
+ // seen, then keep the ones whose instant is in range.
144
+ const day = 24 * 60 * 60 * 1000;
145
+ const hits = rule.between(new Date(toWallUtc(new Date(from.getTime() - day), timeZone).epochMilliseconds), new Date(toWallUtc(new Date(to.getTime() + day), timeZone).epochMilliseconds), true);
146
+ const seen = new Set();
147
+ const out = [];
148
+ for (const hit of hits) {
149
+ const slot = fromWallUtc(hit, timeZone);
150
+ const t = slot.getTime();
151
+ if (t < from.getTime() || t > to.getTime() || seen.has(t))
152
+ continue;
153
+ seen.add(t);
154
+ out.push(slot);
155
+ }
156
+ return out;
157
+ }
158
+ };
159
+ // The minimum interval, measured on the rule's first slots from its start.
160
+ let previous = schedule.nextSlot(new Date(fromWallUtc(start, timeZone).getTime() - 1));
161
+ for (let i = 0; previous && i < 48; i++) {
162
+ const next = schedule.nextSlot(previous);
163
+ if (!next)
164
+ break;
165
+ if (next.getTime() - previous.getTime() < minIntervalMs) {
166
+ throw new Error(`Schedule "${spec.rrule}": slots ${Math.round((next.getTime() - previous.getTime()) / 1000)} s apart are closer than the minimum interval of ${Math.round(minIntervalMs / 1000)} s`);
167
+ }
168
+ previous = next;
169
+ }
170
+ return schedule;
171
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Files that hold secrets are owner-only (#1560).
3
+ *
4
+ * The user store (password hashes), the credentials store, the instance
5
+ * `.env` and backups were written with the process's default mode, which on
6
+ * most systems is world-readable (`-rw-r--r--`). They are written `0600` in
7
+ * `0700` directories now, and at boot an existing file is checked:
8
+ *
9
+ * - wider than `0600` — tightened, and said so;
10
+ * - owned by another account than the server runs as — the boot refuses,
11
+ * naming both, since the server could neither secure nor safely trust it.
12
+ *
13
+ * No UID is configured anywhere: the mode is set on write, so the owner is
14
+ * whoever runs the server (a login, `node` in the image, the pod's user).
15
+ * Permissions keep out other local accounts and processes, not root; anyone
16
+ * with root, the disk or a backup is outside what they protect against —
17
+ * backup encryption, an optional setting still to be built (#1561), is for that.
18
+ */
19
+ import fs from 'node:fs';
20
+ export const SECRET_FILE_MODE = 0o600;
21
+ export const SECRET_DIR_MODE = 0o700;
22
+ /** The server's own UID, or null where the platform has none (Windows). */
23
+ function ownUid() {
24
+ return typeof process.getuid === 'function' ? process.getuid() : null;
25
+ }
26
+ /**
27
+ * Check an existing secret file at boot. Missing is fine (it is created
28
+ * owner-only on first write). Returns a line to log when it was tightened.
29
+ * Throws, naming both UIDs, when another account owns it.
30
+ */
31
+ export function secureExistingSecretFile(file) {
32
+ let st;
33
+ try {
34
+ st = fs.statSync(file);
35
+ }
36
+ catch (err) {
37
+ if (err.code === 'ENOENT')
38
+ return null;
39
+ throw err;
40
+ }
41
+ const uid = ownUid();
42
+ if (uid !== null && st.uid !== uid) {
43
+ throw new Error(`Refusing to start: ${file} holds secrets and is owned by UID ${st.uid}, but the server runs as UID ${uid}. ` +
44
+ 'Give it to the server\'s account (chown) or start the server as its owner (#1560).');
45
+ }
46
+ if ((st.mode & 0o077) === 0)
47
+ return null;
48
+ fs.chmodSync(file, SECRET_FILE_MODE);
49
+ return `🔐 Tightened ${file} from ${(st.mode & 0o777).toString(8)} to 600: it holds secrets (#1560)`;
50
+ }
51
+ /**
52
+ * Make a directory holding secrets owner-only, creating it when absent.
53
+ * Best effort on a filesystem that does not honour modes (an SMB share):
54
+ * returns a warning instead of failing, since refusing to write a backup
55
+ * over it would be worse than writing it with the share's permissions.
56
+ */
57
+ export function ensureSecretDir(dir) {
58
+ fs.mkdirSync(dir, { recursive: true, mode: SECRET_DIR_MODE });
59
+ try {
60
+ const st = fs.statSync(dir);
61
+ if ((st.mode & 0o077) !== 0)
62
+ fs.chmodSync(dir, SECRET_DIR_MODE);
63
+ return null;
64
+ }
65
+ catch (err) {
66
+ return `Could not make ${dir} owner-only (${err.message}); its filesystem may not support permissions (#1560)`;
67
+ }
68
+ }
69
+ /** After writing a secret file directly (not through writeFileAtomic's mode), make it owner-only. */
70
+ export function chmodSecretFile(file) {
71
+ try {
72
+ fs.chmodSync(file, SECRET_FILE_MODE);
73
+ return null;
74
+ }
75
+ catch (err) {
76
+ return `Could not make ${file} owner-only (${err.message}); its filesystem may not support permissions (#1560)`;
77
+ }
78
+ }
@@ -1,12 +1,8 @@
1
+ import type { SiteRestoreStaging, StagedRestoreAtStart, StagedSwap } from '../managers/BaseManager.js';
1
2
  /** The config key naming the site restore manifest. */
2
3
  export declare const SITE_RESTORE_MANIFEST_KEY = "ngdpbase.backup.restore-manifest";
3
4
  /** Its default: beside the other instance data in FAST_STORAGE. */
4
5
  export declare const SITE_RESTORE_MANIFEST_DEFAULT = "./data/site-restore.json";
5
- /** One live path and the staged copy that replaces it at the next start. */
6
- export interface StagedSwap {
7
- live: string;
8
- staged: string;
9
- }
10
6
  /** One manager's share of a site restore. */
11
7
  export interface SiteRestorePart {
12
8
  manager: string;
@@ -48,6 +44,12 @@ export declare function readSiteRestore(file: string): SiteRestoreManifest | nul
48
44
  export declare function beginSiteRestore(file: string, meta: Omit<SiteRestoreManifest, 'version' | 'state' | 'parts'>): void;
49
45
  /** Record one owner's staged part. Only while staging. */
50
46
  export declare function recordStagedPart(file: string, part: SiteRestorePart): void;
47
+ /**
48
+ * The site restore being staged, as one manager's `restore()` gets it
49
+ * (`ManagerRestoreOptions.siteRestore`, #1986): its part is recorded under
50
+ * that manager's name, so a manager records its own part and no other.
51
+ */
52
+ export declare function stagingFor(file: string, manager: string): SiteRestoreStaging;
51
53
  /** The commit: every part is staged. */
52
54
  export declare function commitSiteRestore(file: string): SiteRestoreManifest;
53
55
  /**
@@ -80,27 +82,22 @@ export declare function gatePart(file: string, manager: string): PartGate;
80
82
  * of the set. When every part is done the manifest becomes `<file>.done`.
81
83
  */
82
84
  export declare function markPart(file: string, manager: string, status: 'applied' | 'set-aside', note?: string): void;
83
- export interface PartOutcome {
84
- /** Each live path now restored, with where its previous content was kept. */
85
- applied: Array<{
86
- live: string;
87
- kept: string;
88
- }>;
89
- /** Why nothing (or, after a failed rename, not everything) was applied. */
90
- refused?: string;
91
- }
92
85
  /**
93
86
  * Apply `manager`'s part of a staged site restore, at start, before the
94
87
  * manager reads its data. `allowed` says which live paths this manager may
95
88
  * replace, so a tampered manifest cannot rename an arbitrary file.
96
89
  * `strays` lists staged paths of this manager's that exist on disk, found by
97
90
  * the manager: with no committed restore naming them they are set aside.
98
- * Returns null when there was nothing to do.
91
+ * `put` puts one swap in place instead of the rename, for a part whose live
92
+ * path is not one file or folder of its own (the audit files share the log
93
+ * folder): it moves the live content to `kept` and the staged one in, and
94
+ * throws to refuse. Returns null when there was nothing to do.
99
95
  */
100
96
  export declare function applyPartAtStart(file: string, manager: string, options: {
101
97
  allowed: (swap: StagedSwap) => boolean;
102
98
  strays?: () => string[];
103
- }): PartOutcome | null;
99
+ put?: (swap: StagedSwap, kept: string) => void;
100
+ }): StagedRestoreAtStart | null;
104
101
  /**
105
102
  * What a start did with a site restore, for BackupManager to tell admins at
106
103
  * the end of the start: the finished record (`<file>.done`, then kept as
@@ -36,6 +36,14 @@
36
36
  *
37
37
  * Everything here is synchronous file renames on the owner's own paths: it
38
38
  * runs once at start, before the owner serves anything.
39
+ *
40
+ * __BackupManager's own__ (#1986): only BackupManager and the backup
41
+ * internals (`directoryBackup.ts`, and `instanceEnvBackup.ts`, which applies
42
+ * the staged `.env` at the very start of the process, before BackupManager
43
+ * can be loaded) import this module, enforced by `npm run lint:backup`. A
44
+ * manager or provider records its part through the `siteRestore` restore
45
+ * option and applies it at start through BackupManager's
46
+ * `applyStagedPartAtStart` / `siteRestorePartAtStart`.
39
47
  */
40
48
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
41
49
  import path from 'node:path';
@@ -92,6 +100,17 @@ export function recordStagedPart(file, part) {
92
100
  manifest.parts = [...manifest.parts.filter((p) => p.manager !== part.manager), { manager: part.manager, swaps: part.swaps, ...(part.discard ? { discard: part.discard } : {}) }];
93
101
  writeSiteRestore(file, manifest);
94
102
  }
103
+ /**
104
+ * The site restore being staged, as one manager's `restore()` gets it
105
+ * (`ManagerRestoreOptions.siteRestore`, #1986): its part is recorded under
106
+ * that manager's name, so a manager records its own part and no other.
107
+ */
108
+ export function stagingFor(file, manager) {
109
+ return {
110
+ stagedPathFor,
111
+ record: (swaps, options) => recordStagedPart(file, { manager, swaps, ...(options?.discard ? { discard: options.discard } : {}) })
112
+ };
113
+ }
95
114
  /** The commit: every part is staged. */
96
115
  export function commitSiteRestore(file) {
97
116
  const manifest = readSiteRestore(file);
@@ -218,7 +237,10 @@ export function markPart(file, manager, status, note) {
218
237
  * replace, so a tampered manifest cannot rename an arbitrary file.
219
238
  * `strays` lists staged paths of this manager's that exist on disk, found by
220
239
  * the manager: with no committed restore naming them they are set aside.
221
- * Returns null when there was nothing to do.
240
+ * `put` puts one swap in place instead of the rename, for a part whose live
241
+ * path is not one file or folder of its own (the audit files share the log
242
+ * folder): it moves the live content to `kept` and the staged one in, and
243
+ * throws to refuse. Returns null when there was nothing to do.
222
244
  */
223
245
  export function applyPartAtStart(file, manager, options) {
224
246
  const gate = gatePart(file, manager);
@@ -252,11 +274,16 @@ export function applyPartAtStart(file, manager, options) {
252
274
  try {
253
275
  for (const swap of gate.part.swaps) {
254
276
  const kept = keptPathFor(swap.live);
255
- rmSync(kept, { recursive: true, force: true });
256
- if (existsSync(swap.live))
257
- renameSync(swap.live, kept);
258
- mkdirSync(path.dirname(swap.live), { recursive: true });
259
- renameSync(swap.staged, swap.live);
277
+ if (options.put) {
278
+ options.put(swap, kept);
279
+ }
280
+ else {
281
+ rmSync(kept, { recursive: true, force: true });
282
+ if (existsSync(swap.live))
283
+ renameSync(swap.live, kept);
284
+ mkdirSync(path.dirname(swap.live), { recursive: true });
285
+ renameSync(swap.staged, swap.live);
286
+ }
260
287
  applied.push({ live: swap.live, kept });
261
288
  }
262
289
  }
@@ -16,6 +16,8 @@ export interface TreeArchiveOutcome {
16
16
  * Every other error (EACCES, EIO, …) is a real failure and still fails.
17
17
  */
18
18
  export declare function vanishedDuringBackup(err: unknown): boolean;
19
+ /** The log's note of entries deleted while they were backed up (#1563): skipped, not a failure. */
20
+ export declare function vanishedNote(vanished: string[]): string;
19
21
  type TreeArchiveOptions = {
20
22
  entries?: string[];
21
23
  exclude?: (relative: string) => boolean;
@@ -0,0 +1,242 @@
1
+ /**
2
+ * A folder of a site's files as one `.tar` beside a backup document, and back
3
+ * (#1876).
4
+ *
5
+ * The backup document is JSON built in memory, so file contents never go in
6
+ * it: a manager whose data is files (attachments, role and person records,
7
+ * footnotes, comments) writes them here instead, as `<stem>.<part>.tar`
8
+ * beside the document, the way DatabaseManager writes `<stem>.<name>.db`.
9
+ * BackupManager prunes and downloads these with the document.
10
+ *
11
+ * - __Streamed__ both ways: each file is read from disk as the archive is
12
+ * written, and written to disk as it is read, so a file's size never
13
+ * becomes memory.
14
+ * - __Bytes as stored__: nothing is decoded, so a sealed vault file stays the
15
+ * sealed bytes it is on disk.
16
+ * - __Only files and folders__: a symbolic link or any other kind of entry is
17
+ * left out of a backup, and named; one in an archive refuses the extract.
18
+ * - __Paths are data__: an extract refuses an entry that is absolute or
19
+ * climbs out of its folder (`strict`), never writing outside `dest`.
20
+ * - __Encrypted under the backup key__ when one is given (#1561, #1876): the
21
+ * archive is streamed through `backupCrypto.ts` as it is written, and an
22
+ * extract or a listing given the key decrypts it as it reads. One that is
23
+ * encrypted is never read without the key, one made under another key is
24
+ * refused by its fingerprint, and given a key a plain archive is refused:
25
+ * it is not the one the backup wrote.
26
+ */
27
+ import { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
28
+ import path from 'node:path';
29
+ import { Readable } from 'node:stream';
30
+ import { pipeline } from 'node:stream/promises';
31
+ import { create as createTar, extract as extractTar, list as listTar } from 'tar';
32
+ import { decryptBuffer, decryptStream, encryptBuffer, encryptStream, fileIsEncrypted } from './backupCrypto.js';
33
+ import { BACKUP_KEY_ENV } from './backupKey.js';
34
+ /**
35
+ * Whether a backup's read failed because the entry it was reading was
36
+ * deleted meanwhile (#1563): a page, a version folder or a file removed
37
+ * while the backup walked past it. That entry is skipped and counted, never
38
+ * a failed backup — the site keeps running while it is backed up (#1213).
39
+ * Every other error (EACCES, EIO, …) is a real failure and still fails.
40
+ */
41
+ export function vanishedDuringBackup(err) {
42
+ return err?.code === 'ENOENT';
43
+ }
44
+ /** The log's note of entries deleted while they were backed up (#1563): skipped, not a failure. */
45
+ export function vanishedNote(vanished) {
46
+ return vanished.length > 0 ? `; ${vanished.length} entr${vanished.length === 1 ? 'y' : 'ies'} deleted during the backup skipped (${vanished.slice(0, 5).join(', ')})` : '';
47
+ }
48
+ /** Two 512-byte zero blocks: a tar with no entries. */
49
+ const EMPTY_TAR = Buffer.alloc(1024);
50
+ /**
51
+ * Write `entries` (paths relative to `root`; default: everything in it) into
52
+ * `target`, streamed. `exclude` leaves out a path relative to `root`, in
53
+ * posix form. A missing `root` writes an empty archive. Never overwrites.
54
+ *
55
+ * An entry deleted while the archive is written (#1563) — the tar stream
56
+ * stops at the first file it can no longer find — is not a failure: the
57
+ * archive is written again from a fresh listing, without it, and the entry
58
+ * is named in `vanished`. It is written again only while each attempt finds
59
+ * another entry gone, so it ends once the deleting stops; the same entry
60
+ * missing twice, or one still there, is a real failure.
61
+ */
62
+ export async function writeTreeArchive(target, root, options = {}) {
63
+ if (existsSync(target))
64
+ throw new Error(`${path.basename(target)} already exists; a backup never overwrites one`);
65
+ const vanished = new Set();
66
+ for (;;) {
67
+ try {
68
+ const outcome = await writeTreeArchiveOnce(target, root, options);
69
+ return { ...outcome, vanished: [...vanished].map((p) => toPosix(path.relative(root, p))).sort() };
70
+ }
71
+ catch (err) {
72
+ const gone = err.path;
73
+ if (!vanishedDuringBackup(err) || !gone || vanished.has(gone) || existsSync(gone))
74
+ throw err;
75
+ vanished.add(gone);
76
+ }
77
+ }
78
+ }
79
+ async function writeTreeArchiveOnce(target, root, options) {
80
+ const outcome = { files: 0, bytes: 0, skipped: [] };
81
+ const entries = (options.entries ?? (existsSync(root) ? readdirSync(root) : []))
82
+ .filter((e) => existsSync(path.join(root, e)))
83
+ .filter((e) => !options.exclude?.(toPosix(e)))
84
+ .sort();
85
+ if (entries.length === 0) {
86
+ writeFileSync(target, options.backupKey ? await encryptBuffer(EMPTY_TAR, options.backupKey) : EMPTY_TAR, { mode: 0o600, flag: 'wx' });
87
+ return outcome;
88
+ }
89
+ try {
90
+ const tarOptions = {
91
+ cwd: root,
92
+ portable: true,
93
+ filter: (p, stat) => {
94
+ const relative = toPosix(path.normalize(p));
95
+ if (options.exclude?.(relative))
96
+ return false;
97
+ const s = stat;
98
+ if (s.isDirectory())
99
+ return true;
100
+ if (!s.isFile()) {
101
+ outcome.skipped.push(relative);
102
+ return false;
103
+ }
104
+ outcome.files++;
105
+ outcome.bytes += s.size;
106
+ return true;
107
+ }
108
+ };
109
+ if (options.backupKey) {
110
+ const pack = createTar(tarOptions, entries);
111
+ await pipeline(pack, encryptStream(options.backupKey), createWriteStream(target, { flags: 'wx', mode: 0o600 }));
112
+ }
113
+ else {
114
+ await createTar({ ...tarOptions, file: target, mode: 0o600 }, entries);
115
+ }
116
+ }
117
+ catch (err) {
118
+ rmSync(target, { force: true });
119
+ throw err;
120
+ }
121
+ return outcome;
122
+ }
123
+ /**
124
+ * Extract `archive` into `dest`, which is emptied first, streamed.
125
+ * `select(relative)` picks the entries to extract (default: all). Refuses —
126
+ * removing `dest` — an entry that is not a file or a folder, an absolute
127
+ * path, or one that climbs out (`..`).
128
+ */
129
+ export async function extractTreeArchive(archive, dest, options = {}) {
130
+ if (!existsSync(archive) || !statSync(archive).isFile())
131
+ throw new Error(`${path.basename(archive)} is missing`);
132
+ const source = await openArchive(archive, options.backupKey);
133
+ rmSync(dest, { recursive: true, force: true });
134
+ mkdirSync(dest, { recursive: true });
135
+ // An archive of an empty folder is only its end blocks, which a strict read refuses.
136
+ if (source.empty)
137
+ return { files: 0, bytes: 0 };
138
+ const refused = [];
139
+ let files = 0;
140
+ let bytes = 0;
141
+ try {
142
+ const unpackOptions = {
143
+ cwd: dest,
144
+ strict: true,
145
+ preservePaths: false,
146
+ preserveOwner: false,
147
+ ...(options.strip ? { strip: options.strip } : {}),
148
+ filter: (p, entry) => {
149
+ const e = entry;
150
+ const relative = toPosix(p).replace(/^\.\//, '');
151
+ if (options.select && !options.select(relative))
152
+ return false;
153
+ if (!['File', 'OldFile', 'ContiguousFile', 'Directory'].includes(e.type)) {
154
+ refused.push(`${relative} (${e.type})`);
155
+ return false;
156
+ }
157
+ if (e.type !== 'Directory') {
158
+ files++;
159
+ bytes += e.size ?? 0;
160
+ }
161
+ return true;
162
+ }
163
+ };
164
+ if (source.into)
165
+ await source.into(extractTar(unpackOptions));
166
+ else
167
+ await extractTar({ ...unpackOptions, file: archive });
168
+ }
169
+ catch (err) {
170
+ rmSync(dest, { recursive: true, force: true });
171
+ throw new Error(`${path.basename(archive)} could not be read: ${err.message}`, { cause: err });
172
+ }
173
+ if (refused.length > 0) {
174
+ rmSync(dest, { recursive: true, force: true });
175
+ throw new Error(`${path.basename(archive)} holds entries that are not files or folders: ${refused.slice(0, 5).join(', ')}`);
176
+ }
177
+ return { files, bytes };
178
+ }
179
+ /** An encrypted archive this small is decrypted whole, to recognise an empty one; anything larger streams. */
180
+ const SMALL_ENCRYPTED = 64 * 1024;
181
+ /**
182
+ * How to read an archive: plain (`stream` absent, tar reads the file), or
183
+ * decrypted as a stream. `empty` when it holds no entry at all. Throws for an
184
+ * encrypted archive with no key or another key, before anything is written.
185
+ */
186
+ async function openArchive(archive, backupKey) {
187
+ const name = path.basename(archive);
188
+ if (!(await fileIsEncrypted(archive))) {
189
+ // A backup that says its archive is encrypted never takes a plain one in its place.
190
+ if (backupKey)
191
+ throw new Error(`${name} is not encrypted, but the backup says it is: it was replaced or damaged`);
192
+ const empty = statSync(archive).size === EMPTY_TAR.length && (await readHead(archive, EMPTY_TAR.length)).equals(EMPTY_TAR);
193
+ return { empty };
194
+ }
195
+ if (!backupKey)
196
+ throw new Error(`${name} is encrypted under a backup key, and none was given to read it (${BACKUP_KEY_ENV} is not set, or the backup does not say the archive is encrypted)`);
197
+ if (statSync(archive).size <= SMALL_ENCRYPTED) {
198
+ let plain;
199
+ try {
200
+ plain = await decryptBuffer(await readHead(archive, SMALL_ENCRYPTED), backupKey);
201
+ }
202
+ catch (err) {
203
+ throw new Error(`${name} could not be read: ${err.message}`, { cause: err });
204
+ }
205
+ if (plain.length === EMPTY_TAR.length && plain.equals(EMPTY_TAR))
206
+ return { empty: true };
207
+ return { empty: false, into: (sink) => pipeline(Readable.from([plain]), sink) };
208
+ }
209
+ // The decrypting stream checks the header (a wrong key is refused) before it passes on any byte.
210
+ return { empty: false, into: (sink) => pipeline(createReadStream(archive), decryptStream(backupKey), sink) };
211
+ }
212
+ /**
213
+ * Every entry in an archive, plain or encrypted, read as a stream: what a
214
+ * caller checks before extracting. `onEntry` gets each entry's path and type.
215
+ */
216
+ export async function listTreeArchive(archive, onEntry, options = {}) {
217
+ if (!existsSync(archive) || !statSync(archive).isFile())
218
+ throw new Error(`${path.basename(archive)} is missing`);
219
+ const source = await openArchive(archive, options.backupKey);
220
+ if (source.empty)
221
+ return;
222
+ const listOptions = { onReadEntry: (e) => onEntry({ path: e.path, type: e.type }) };
223
+ if (source.into)
224
+ await source.into(listTar(listOptions));
225
+ else
226
+ await listTar({ ...listOptions, file: archive });
227
+ }
228
+ /** The first `n` bytes of a file, read without reading the rest. */
229
+ export async function readHead(file, n) {
230
+ const chunks = [];
231
+ let total = 0;
232
+ if (n <= 0)
233
+ return Buffer.alloc(0);
234
+ for await (const chunk of createReadStream(file, { start: 0, end: n - 1 })) {
235
+ chunks.push(chunk);
236
+ total += chunk.length;
237
+ }
238
+ return Buffer.concat(chunks, total);
239
+ }
240
+ function toPosix(p) {
241
+ return p.split(path.sep).join('/');
242
+ }