@jwilleke/ngdpbase-types 5.24.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 +2 -2
- package/lib/packages/ngdpbase-types/index.d.ts +6 -2
- package/lib/packages/ngdpbase-types/testing.d.ts +7 -2
- package/lib/packages/ngdpbase-types/testing.js +7 -2
- 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 +119 -2
- 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/security/PolicyInformationPoint.d.ts +8 -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 +51 -3
- package/lib/src/utils/addonHost.js +29 -2
- package/lib/src/utils/addonPageSync.d.ts +2 -2
- 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,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A secret that must be defined in the instance `.env`, generated once when
|
|
3
|
+
* nothing supplies it (#1194, generalised for #1524).
|
|
4
|
+
*
|
|
5
|
+
* The session secret was the first; the credentials-store key is the second.
|
|
6
|
+
* Both follow one rule, so it is written once:
|
|
7
|
+
*
|
|
8
|
+
* 1. Set in the environment — use it, write nothing.
|
|
9
|
+
* 2. Blank in the environment, but the instance `.env` has a usable line — use
|
|
10
|
+
* that (a launcher passing `NAME=` empty would otherwise regenerate on every
|
|
11
|
+
* boot).
|
|
12
|
+
* 3. Otherwise generate, append one commented line to `<instanceDataDir>/.env`
|
|
13
|
+
* (created `0600` when new), and use it.
|
|
14
|
+
*
|
|
15
|
+
* The repo-root `.env` is never written: it is shared by every instance that
|
|
16
|
+
* launches from the checkout.
|
|
17
|
+
*/
|
|
18
|
+
import fs from 'fs';
|
|
19
|
+
import path from 'path';
|
|
20
|
+
import { randomBytes } from 'crypto';
|
|
21
|
+
export function ensureInstanceEnvSecret(spec, env, instanceDataDir, fs) {
|
|
22
|
+
const accept = spec.accept ?? ((v) => v);
|
|
23
|
+
const ambient = (env[spec.name] ?? '').trim();
|
|
24
|
+
if (ambient !== '')
|
|
25
|
+
return { secret: accept(ambient), origin: { kind: 'env' } };
|
|
26
|
+
const envPath = `${instanceDataDir.replace(/\/+$/, '')}/.env`;
|
|
27
|
+
const existing = fs.readFile(envPath);
|
|
28
|
+
if (existing !== null) {
|
|
29
|
+
const fromFile = readEnvLine(existing, spec.name);
|
|
30
|
+
if (fromFile !== null)
|
|
31
|
+
return { secret: accept(fromFile), origin: { kind: 'instance-env-file', path: envPath } };
|
|
32
|
+
}
|
|
33
|
+
const secret = fs.randomSecret();
|
|
34
|
+
const needsLeadingNewline = existing !== null && existing !== '' && !existing.endsWith('\n');
|
|
35
|
+
const line = (needsLeadingNewline ? '\n' : '') + `# ${spec.comment}\n${spec.name}=${secret}\n`;
|
|
36
|
+
try {
|
|
37
|
+
fs.appendFile(envPath, line, 0o600);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
throw spec.refusal(envPath, err);
|
|
41
|
+
}
|
|
42
|
+
return { secret, origin: { kind: 'generated', path: envPath } };
|
|
43
|
+
}
|
|
44
|
+
/** The last `NAME=` line in a dotenv file, unquoted; null when absent or blank. */
|
|
45
|
+
export function readEnvLine(content, name) {
|
|
46
|
+
const pattern = new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=\\s*(.*)$`);
|
|
47
|
+
let found = null;
|
|
48
|
+
for (const raw of content.split('\n')) {
|
|
49
|
+
const m = raw.match(pattern);
|
|
50
|
+
if (!m)
|
|
51
|
+
continue;
|
|
52
|
+
let value = m[1].trim();
|
|
53
|
+
const quoted = value.match(/^(['"])(.*)\1$/);
|
|
54
|
+
if (quoted)
|
|
55
|
+
value = quoted[2];
|
|
56
|
+
else
|
|
57
|
+
value = value.replace(/\s+#.*$/, '').trim();
|
|
58
|
+
found = value;
|
|
59
|
+
}
|
|
60
|
+
return found === '' ? null : found;
|
|
61
|
+
}
|
|
62
|
+
/** The real filesystem behind every generated `.env` secret. */
|
|
63
|
+
export const nodeInstanceEnvFs = {
|
|
64
|
+
readFile: (p) => {
|
|
65
|
+
try {
|
|
66
|
+
return fs.readFileSync(p, 'utf8');
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
if (err.code === 'ENOENT')
|
|
70
|
+
return null;
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
appendFile: (p, line, mode) => {
|
|
75
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
76
|
+
fs.appendFileSync(p, line, { mode });
|
|
77
|
+
},
|
|
78
|
+
randomSecret: () => randomBytes(32).toString('base64')
|
|
79
|
+
};
|
|
@@ -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;
|