@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.
- package/README.md +1 -1
- package/lib/packages/ngdpbase-types/index.d.ts +4 -0
- package/lib/src/http/egressPolicy.js +61 -0
- package/lib/src/http/guardedElasticsearch.d.ts +27 -0
- package/lib/src/http/guardedElasticsearch.js +99 -0
- package/lib/src/managers/AddonsManager.d.ts +37 -1
- package/lib/src/managers/AttachmentManager.d.ts +4 -5
- package/lib/src/managers/AuthManager.d.ts +150 -32
- package/lib/src/managers/BackgroundJobManager.d.ts +22 -12
- package/lib/src/managers/BackupManager.d.ts +779 -0
- package/lib/src/managers/BackupManager.js +2042 -0
- package/lib/src/managers/BaseManager.d.ts +156 -6
- package/lib/src/managers/BaseManager.js +64 -2
- package/lib/src/managers/CommentManager.d.ts +6 -6
- package/lib/src/managers/ConsentManager.d.ts +130 -0
- package/lib/src/managers/DatabaseManager.d.ts +6 -0
- package/lib/src/managers/DatabaseManager.js +25 -15
- package/lib/src/managers/FootnoteManager.d.ts +6 -6
- package/lib/src/managers/OrganizationManager.d.ts +8 -6
- package/lib/src/managers/PageManager.d.ts +16 -0
- package/lib/src/managers/PersonManager.d.ts +8 -6
- package/lib/src/managers/RoleManager.d.ts +6 -8
- package/lib/src/providers/BaseAttachmentProvider.d.ts +4 -4
- package/lib/src/providers/BaseBackupProvider.d.ts +77 -0
- package/lib/src/providers/BaseBackupProvider.js +52 -0
- package/lib/src/providers/BasicAttachmentProvider.d.ts +4 -4
- package/lib/src/providers/FileBackupProvider.d.ts +34 -0
- package/lib/src/providers/FileBackupProvider.js +116 -0
- package/lib/src/providers/FileConsentProvider.d.ts +18 -0
- package/lib/src/providers/SqliteDatabaseProvider.d.ts +2 -2
- package/lib/src/providers/SqliteDatabaseProvider.js +12 -11
- package/lib/src/providers/attachmentFilesBackup.d.ts +5 -4
- package/lib/src/providers/databaseMigrations.js +45 -0
- package/lib/src/security/PolicyDecisionPoint.d.ts +14 -0
- package/lib/src/types/Consent.d.ts +90 -0
- package/lib/src/types/express.d.ts +6 -0
- package/lib/src/utils/addonHost.d.ts +22 -0
- package/lib/src/utils/addonHost.js +14 -1
- package/lib/src/utils/auditEventNames.generated.d.ts +20 -0
- package/lib/src/utils/auditEventNames.generated.js +20 -0
- package/lib/src/utils/backupCrypto.js +275 -0
- package/lib/src/utils/backupDocument.d.ts +36 -0
- package/lib/src/utils/backupDocument.js +99 -0
- package/lib/src/utils/browserDescription.d.ts +11 -0
- package/lib/src/utils/directoryBackup.d.ts +48 -48
- package/lib/src/utils/directoryBackup.js +291 -0
- package/lib/src/utils/instanceEnvBackup.d.ts +46 -0
- package/lib/src/utils/instanceEnvBackup.js +120 -0
- package/lib/src/utils/instanceEnvSecret.js +79 -0
- package/lib/src/utils/ipLocation.d.ts +11 -0
- package/lib/src/utils/schedule.js +171 -0
- package/lib/src/utils/secretFileMode.js +78 -0
- package/lib/src/utils/siteRestore.d.ts +13 -16
- package/lib/src/utils/siteRestore.js +33 -6
- package/lib/src/utils/treeArchive.d.ts +2 -0
- package/lib/src/utils/treeArchive.js +242 -0
- package/lib/src/utils/version.js +408 -0
- package/package.json +6 -1
- 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
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
+
}
|