@jwilleke/ngdpbase-types 5.39.0 → 5.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/packages/ngdpbase-types/index.d.ts +1 -1
- package/lib/src/context/ApiContext.d.ts +3 -2
- package/lib/src/context/ApiContext.js +4 -2
- package/lib/src/ice/fileHandling.d.ts +6 -1
- package/lib/src/ice/givenName.d.ts +19 -0
- package/lib/src/ice/runExport.d.ts +41 -0
- package/lib/src/ice/runImport.d.ts +37 -8
- package/lib/src/ice/shims/csv.d.ts +24 -0
- package/lib/src/ice/shims/export.d.ts +23 -0
- package/lib/src/ice/shims/index.d.ts +3 -1
- package/lib/src/ice/shims/svg.d.ts +27 -0
- package/lib/src/ice/shims/takeout.d.ts +83 -0
- package/lib/src/ice/types.d.ts +301 -5
- package/lib/src/ice/vet.d.ts +19 -0
- package/lib/src/managers/AttachmentManager.d.ts +100 -36
- package/lib/src/managers/AuthManager.d.ts +152 -12
- package/lib/src/managers/AuthManager.js +3830 -0
- package/lib/src/managers/BackgroundJobManager.d.ts +25 -0
- package/lib/src/managers/BackupManager.d.ts +24 -0
- package/lib/src/managers/BackupManager.js +48 -1
- package/lib/src/managers/ExportManager.d.ts +16 -0
- package/lib/src/managers/ExportManager.js +25 -0
- package/lib/src/managers/FileManager.d.ts +182 -0
- package/lib/src/managers/ImportManager.d.ts +279 -16
- package/lib/src/managers/MediaManager.d.ts +2 -0
- package/lib/src/managers/PageManager.d.ts +53 -0
- package/lib/src/managers/SearchManager.d.ts +4 -0
- package/lib/src/managers/SharedFolderManager.d.ts +320 -0
- package/lib/src/managers/ValidationManager.d.ts +5 -2
- package/lib/src/middleware/csrf.js +99 -0
- package/lib/src/providers/AgentTokenAuthProvider.js +46 -0
- package/lib/src/providers/AuthentikBearerAuthProvider.js +176 -0
- package/lib/src/providers/BaseCredentialsProvider.js +21 -0
- package/lib/src/providers/BaseMediaProvider.d.ts +4 -0
- package/lib/src/providers/BasePageProvider.d.ts +8 -0
- package/lib/src/providers/CloudflareAccessAuthProvider.js +160 -0
- package/lib/src/providers/FileCredentialsProvider.js +188 -0
- package/lib/src/providers/FileSystemMediaProvider.d.ts +6 -0
- package/lib/src/providers/MagicLinkAuthProvider.js +333 -0
- package/lib/src/providers/OidcAuthProvider.js +350 -0
- package/lib/src/providers/OidcBearerAuthProvider.js +27 -0
- package/lib/src/providers/PasskeyAuthProvider.js +147 -0
- package/lib/src/providers/PasswordAuthProvider.js +44 -0
- package/lib/src/security/PolicyDecisionPoint.d.ts +3 -2
- package/lib/src/security/pageActions.js +43 -0
- package/lib/src/security/permissions.generated.d.ts +7 -3
- package/lib/src/types/File.d.ts +90 -0
- package/lib/src/types/Provider.d.ts +15 -0
- package/lib/src/types/express.d.ts +7 -1
- package/lib/src/types/guards.js +366 -0
- package/lib/src/types/linkedom.d.ts +2 -0
- package/lib/src/utils/atomicWrite.js +129 -0
- package/lib/src/utils/auditEventNames.generated.d.ts +12 -0
- package/lib/src/utils/auditEventNames.generated.js +12 -0
- package/lib/src/utils/auditEvents.d.ts +15 -0
- package/lib/src/utils/auditEvents.js +17 -1
- package/lib/src/utils/bip39English.js +2 -0
- package/lib/src/utils/browserDescription.js +28 -0
- package/lib/src/utils/delegation.js +79 -0
- package/lib/src/utils/deriveUsername.js +38 -0
- package/lib/src/utils/emailForLog.js +28 -0
- package/lib/src/utils/fileMigrationPlan.d.ts +81 -0
- package/lib/src/utils/instanceEnvBackup.d.ts +6 -0
- package/lib/src/utils/instanceEnvBackup.js +13 -0
- package/lib/src/utils/ipLocation.js +106 -0
- package/lib/src/utils/magicLinkDeviceState.js +97 -0
- package/lib/src/utils/pageUrl.d.ts +1 -0
- package/lib/src/utils/passwordHash.js +150 -0
- package/lib/src/utils/permissionCatalog.js +102 -0
- package/lib/src/utils/privateStoreAccess.d.ts +25 -0
- package/lib/src/utils/privateStoreAccess.js +22 -1
- package/lib/src/utils/privateStoreCatalogs.js +57 -0
- package/lib/src/utils/privateStoreCrypto.js +266 -0
- package/lib/src/utils/privateStoreDoor.d.ts +10 -0
- package/lib/src/utils/privateStoreDoor.js +298 -0
- package/lib/src/utils/privateStoreImport.d.ts +0 -10
- package/lib/src/utils/privateStoreMeta.js +123 -0
- package/lib/src/utils/privateStorePath.d.ts +37 -1
- package/lib/src/utils/privateStorePath.js +60 -3
- package/lib/src/utils/privateStoreTakeout.js +149 -0
- package/lib/src/utils/privateStoreUnlock.d.ts +3 -0
- package/lib/src/utils/privateStoreUnlock.js +499 -0
- package/lib/src/utils/recoveryWordsFile.d.ts +54 -0
- package/lib/src/utils/safeRedirect.js +55 -0
- package/lib/src/utils/sessionAccount.js +55 -0
- package/lib/src/utils/sessionEnd.d.ts +1 -0
- package/lib/src/utils/sessionEnd.js +42 -0
- package/lib/src/utils/sessionGeneration.js +29 -0
- package/lib/src/utils/sessionIdle.d.ts +4 -0
- package/lib/src/utils/sessionIdle.js +72 -0
- package/lib/src/utils/sessionLimits.js +72 -0
- package/lib/src/utils/sessionSecurity.js +122 -0
- package/lib/src/utils/shareLockbox.js +101 -0
- package/lib/src/utils/sharedFolderAccess.d.ts +57 -0
- package/lib/src/utils/sharedFolderAccess.js +80 -0
- package/lib/src/utils/userCreateError.js +82 -0
- package/lib/src/utils/username.d.ts +2 -0
- package/lib/src/utils/username.js +105 -0
- package/package.json +2 -1
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The credentials store (#1524): an account's ways in beyond its password.
|
|
3
|
+
*
|
|
4
|
+
* Passwords stay on the user record and are never copied here (decided
|
|
5
|
+
* 2026-10-02). This store holds passkeys, verified email / phone addresses
|
|
6
|
+
* and known devices (TOTP seeds once #421 is built) — one row each, many per
|
|
7
|
+
* account.
|
|
8
|
+
*
|
|
9
|
+
* Every row is signed with `NGDPBASE_CREDENTIALS_KEY` (the instance `.env`).
|
|
10
|
+
* A row that does not verify is never returned: a planted row is ignored, and
|
|
11
|
+
* reported through `onRejected` so the owner of the store raises the alert.
|
|
12
|
+
*
|
|
13
|
+
* AuthManager is the only door; nothing else reads or writes a provider.
|
|
14
|
+
*/
|
|
15
|
+
import BaseProvider from './BaseProvider.js';
|
|
16
|
+
/** The environment variable holding the signing and encryption key. */
|
|
17
|
+
export const CREDENTIALS_KEY_ENV = 'NGDPBASE_CREDENTIALS_KEY';
|
|
18
|
+
export const CREDENTIAL_KINDS = ['passkey', 'totp', 'email', 'sms', 'device', 'identity', 'proxy', 'invitation'];
|
|
19
|
+
class BaseCredentialsProvider extends BaseProvider {
|
|
20
|
+
}
|
|
21
|
+
export default BaseCredentialsProvider;
|
|
@@ -68,8 +68,12 @@ export interface ScanResult {
|
|
|
68
68
|
skippedTruncated?: boolean;
|
|
69
69
|
/** Number of indexed items with no usable capture date (EXIF/QuickTime) — they sort by file mtime (#807) */
|
|
70
70
|
noCaptureDate?: number;
|
|
71
|
+
/** Paths of the first items counted in `noCaptureDate`, capped; the count stays exact (#2191) */
|
|
72
|
+
noCaptureDateFiles?: string[];
|
|
71
73
|
/** Number of indexed items with a partial (year-only / year+month) EXIF date, defaulted to Jan 1 (#808) */
|
|
72
74
|
partialCaptureDate?: number;
|
|
75
|
+
/** Paths of the first items counted in `partialCaptureDate`, capped; the count stays exact (#2191) */
|
|
76
|
+
partialCaptureDateFiles?: string[];
|
|
73
77
|
/** Number of stale index entries pruned because their file vanished from a scanned folder (#867) */
|
|
74
78
|
removed?: number;
|
|
75
79
|
/** Total elapsed time in milliseconds */
|
|
@@ -225,6 +225,14 @@ declare abstract class BasePageProvider extends BaseProvider {
|
|
|
225
225
|
* @returns {string|null} Canonical page title or null
|
|
226
226
|
*/
|
|
227
227
|
abstract findPage(identifier: string): string | null;
|
|
228
|
+
/**
|
|
229
|
+
* The uuid of the page that holds `name` as its title, or as its slug (#2140)
|
|
230
|
+
* @param field - Which name: 'title' or 'slug'
|
|
231
|
+
* @param name - The title or slug
|
|
232
|
+
* @param ctx - Who is asking: a vault page's title is matched in its vault
|
|
233
|
+
* @returns The holder's uuid, or null when no page holds it
|
|
234
|
+
*/
|
|
235
|
+
abstract uuidHoldingName(field: 'title' | 'slug', name: string, ctx: ActorContext): string | null;
|
|
228
236
|
/**
|
|
229
237
|
* Get a page by its UUID
|
|
230
238
|
* @param {string} uuid - Page UUID
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CloudflareAccessAuthProvider — JWT-based trust of Cloudflare Access (#649).
|
|
3
|
+
*
|
|
4
|
+
* Cloudflare Access is a zero-trust gateway: it authenticates users at the
|
|
5
|
+
* edge (against your IdP) and forwards a signed JWT to the origin in the
|
|
6
|
+
* `Cf-Access-Jwt-Assertion` header. This provider verifies that JWT and
|
|
7
|
+
* coordinates the identity to a local User record.
|
|
8
|
+
*
|
|
9
|
+
* Design decisions (per #649 operator-confirmed 2026-05-25):
|
|
10
|
+
* - Q1 — User coordination by **email** (`identity.email` from the JWT).
|
|
11
|
+
* - Q2 — Unknown user → **JIT-provision** with default-role (overridable
|
|
12
|
+
* per CF group via `group-map`).
|
|
13
|
+
* - Q3 — CF `identity.groups[]` → ngdpbase roles via **config-map**.
|
|
14
|
+
*
|
|
15
|
+
* Cloudflare-Access-specific — not a generalised federated-IdP abstraction.
|
|
16
|
+
*
|
|
17
|
+
* Wire-up: this provider is invoked by the request-time middleware in
|
|
18
|
+
* `src/app.ts` (header → verify → JIT-or-lookup → `req.session.username`).
|
|
19
|
+
* It does NOT participate in the explicit /login flow; routes call AuthManager
|
|
20
|
+
* with `providerId='cloudflare-access'` only from the middleware path.
|
|
21
|
+
*
|
|
22
|
+
* Token verification uses `jose.createRemoteJWKSet` against
|
|
23
|
+
* `https://<team-domain>/cdn-cgi/access/certs` — keys are fetched on demand
|
|
24
|
+
* and cached + rotated by jose internally.
|
|
25
|
+
*/
|
|
26
|
+
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
27
|
+
import { jobContextFromRequestWithReason } from '../context/JobContext.js';
|
|
28
|
+
import { systemPrincipalOf } from '../context/bootActions.js';
|
|
29
|
+
import logger from '../utils/logger.js';
|
|
30
|
+
import { emailDomain } from '../utils/emailForLog.js';
|
|
31
|
+
export class CloudflareAccessAuthProvider {
|
|
32
|
+
id = 'cloudflare-access';
|
|
33
|
+
displayName = 'Cloudflare Access';
|
|
34
|
+
/** An identity provider, as Google: AAL1 with no `amr` of its own (#1523). */
|
|
35
|
+
factor = { amr: [], aal: 1, primary: true };
|
|
36
|
+
engine;
|
|
37
|
+
config;
|
|
38
|
+
jwks;
|
|
39
|
+
issuer;
|
|
40
|
+
/**
|
|
41
|
+
* @param jwksOverride optional pre-built JWKS getter for tests; production
|
|
42
|
+
* omits this and uses `createRemoteJWKSet` against the team-domain certs
|
|
43
|
+
* endpoint.
|
|
44
|
+
*/
|
|
45
|
+
constructor(engine, config, jwksOverride) {
|
|
46
|
+
this.engine = engine;
|
|
47
|
+
this.config = config;
|
|
48
|
+
this.issuer = `https://${config.teamDomain}`;
|
|
49
|
+
this.jwks = jwksOverride
|
|
50
|
+
?? createRemoteJWKSet(new URL(`${this.issuer}/cdn-cgi/access/certs`));
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Verify the Cloudflare Access JWT and resolve (or provision) the local user.
|
|
54
|
+
* `credentials.token` is the raw JWT lifted from the `Cf-Access-Jwt-Assertion`
|
|
55
|
+
* header by the request-time middleware.
|
|
56
|
+
*/
|
|
57
|
+
async verify(credentials) {
|
|
58
|
+
const token = credentials.token;
|
|
59
|
+
if (!token) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
let verified;
|
|
63
|
+
try {
|
|
64
|
+
verified = await jwtVerify(token, this.jwks, {
|
|
65
|
+
issuer: this.issuer,
|
|
66
|
+
audience: this.config.applicationAud
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
71
|
+
logger.warn(`[CloudflareAccessAuthProvider] JWT verification failed: ${message}`);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const payload = verified.payload;
|
|
75
|
+
const email = payload.email?.toLowerCase();
|
|
76
|
+
if (!email) {
|
|
77
|
+
logger.warn('[CloudflareAccessAuthProvider] JWT had no email claim');
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
return this.resolveOrProvision(email, payload);
|
|
81
|
+
}
|
|
82
|
+
async resolveOrProvision(email, payload) {
|
|
83
|
+
const userManager = this.engine.getManager('UserManager');
|
|
84
|
+
if (!userManager) {
|
|
85
|
+
logger.error('[CloudflareAccessAuthProvider] UserManager unavailable');
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
// Email-keyed lookup (Q1=a).
|
|
89
|
+
// #2073: an email several accounts share is refused, never guessed between
|
|
90
|
+
// and never given a new account: a gateway's header cannot ask which.
|
|
91
|
+
const holders = await userManager.getUsersByEmail(email);
|
|
92
|
+
if (holders.length > 1) {
|
|
93
|
+
logger.warn(`[CloudflareAccessAuthProvider] ${holders.length} accounts share an address at ${emailDomain(email)}; refused, since a gateway sign-in cannot ask which (#2073)`);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const existing = holders[0];
|
|
97
|
+
if (existing) {
|
|
98
|
+
logger.info(`[CloudflareAccessAuthProvider] Existing user matched: ${existing.username}`);
|
|
99
|
+
return { username: existing.username };
|
|
100
|
+
}
|
|
101
|
+
// JIT-provision (Q2=i). Username derived from local-part of email; if
|
|
102
|
+
// already taken (e.g. existing local-only user with same handle but
|
|
103
|
+
// different email), append a numeric suffix.
|
|
104
|
+
const desiredUsername = email.split('@')[0];
|
|
105
|
+
const username = await this.deriveUniqueUsername(userManager, desiredUsername);
|
|
106
|
+
const displayName = payload.identity?.name || username;
|
|
107
|
+
const roles = this.mapGroupsToRoles(payload.identity?.groups ?? []);
|
|
108
|
+
try {
|
|
109
|
+
const created = await userManager.createUser({
|
|
110
|
+
username,
|
|
111
|
+
email,
|
|
112
|
+
displayName,
|
|
113
|
+
// Federated users have no local password — mirrors GoogleOIDC's pattern
|
|
114
|
+
// (src/providers/GoogleOIDCProvider.ts:215). isExternal=true blocks
|
|
115
|
+
// local password reset; isActive=true allows immediate login.
|
|
116
|
+
password: '',
|
|
117
|
+
roles,
|
|
118
|
+
isExternal: true,
|
|
119
|
+
isActive: true
|
|
120
|
+
}, jobContextFromRequestWithReason({ username: systemPrincipalOf(this.engine) }, 'provisioned by cloudflare-access')); // #1204: provisioned, not registered; #1179: the system principal acts, the reason names the provider
|
|
121
|
+
logger.info(`[CloudflareAccessAuthProvider] JIT-provisioned user: ${username} (an address at ${emailDomain(email)}) with roles=[${roles.join(', ')}]`);
|
|
122
|
+
return { username: created.username }; // #1882: the account as stored
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
126
|
+
logger.error(`[CloudflareAccessAuthProvider] JIT provision failed for ${username}:`, message);
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Map CF identity.groups[] to ngdpbase role names. Groups without an
|
|
132
|
+
* entry in config.groupMap are dropped silently. Result always contains
|
|
133
|
+
* config.defaultRole — even with no group hits, user gets the default.
|
|
134
|
+
*/
|
|
135
|
+
mapGroupsToRoles(groups) {
|
|
136
|
+
const out = new Set([this.config.defaultRole]);
|
|
137
|
+
for (const g of groups) {
|
|
138
|
+
const mapped = this.config.groupMap[g];
|
|
139
|
+
if (mapped)
|
|
140
|
+
out.add(mapped);
|
|
141
|
+
}
|
|
142
|
+
return Array.from(out);
|
|
143
|
+
}
|
|
144
|
+
async deriveUniqueUsername(userManager, desired) {
|
|
145
|
+
const getUser = userManager.getUser;
|
|
146
|
+
if (typeof getUser !== 'function')
|
|
147
|
+
return desired;
|
|
148
|
+
let candidate = desired;
|
|
149
|
+
let suffix = 1;
|
|
150
|
+
// Cap at a sane number — defensive only; collisions are rare in practice.
|
|
151
|
+
while (suffix < 100) {
|
|
152
|
+
const existing = await getUser.call(userManager, candidate);
|
|
153
|
+
if (!existing)
|
|
154
|
+
return candidate;
|
|
155
|
+
candidate = `${desired}${suffix}`;
|
|
156
|
+
suffix++;
|
|
157
|
+
}
|
|
158
|
+
return `${desired}-${Date.now()}`;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The credentials store as one JSON file beside the user store (#1524),
|
|
3
|
+
* `${FAST_STORAGE}/users/credentials.json` by default.
|
|
4
|
+
*
|
|
5
|
+
* - Written owner-only (`0600`) through the atomic save, so the file is never
|
|
6
|
+
* readable by other local accounts, not even mid-write (#1560).
|
|
7
|
+
* - Every row carries `sig`: HMAC-SHA-256 over its fields in a fixed order,
|
|
8
|
+
* keyed by `NGDPBASE_CREDENTIALS_KEY`. On load a row whose signature is
|
|
9
|
+
* missing or wrong is reported and can never sign anyone in; it is kept in
|
|
10
|
+
* quarantine and written back unchanged (#1633), so a lost key costs no
|
|
11
|
+
* data. `scripts/retrust-credentials.ts` re-signs them on purpose.
|
|
12
|
+
* - Nothing here is a password hash. Public keys, (when TOTP is built, #421, encrypted seeds) and token
|
|
13
|
+
* hashes are low value to a reader; a writer is what signing stops.
|
|
14
|
+
*/
|
|
15
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
16
|
+
import fs from 'fs-extra';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import BaseCredentialsProvider, { CREDENTIAL_KINDS } from './BaseCredentialsProvider.js';
|
|
19
|
+
import { writeFileAtomic } from '../utils/atomicWrite.js';
|
|
20
|
+
import { secureExistingSecretFile } from '../utils/secretFileMode.js';
|
|
21
|
+
import logger from '../utils/logger.js';
|
|
22
|
+
/** The signed fields, in a fixed order, so a signature never depends on key order in the file. */
|
|
23
|
+
function canonical(r) {
|
|
24
|
+
return JSON.stringify([r.id, r.username, r.kind, r.subject, r.secret, r.label, r.createdAt, r.lastUsedAt ?? null]);
|
|
25
|
+
}
|
|
26
|
+
function isRecord(r) {
|
|
27
|
+
const o = r;
|
|
28
|
+
return typeof o === 'object' && o !== null &&
|
|
29
|
+
['id', 'username', 'subject', 'secret', 'label', 'createdAt'].every(k => typeof o[k] === 'string') &&
|
|
30
|
+
CREDENTIAL_KINDS.includes(o.kind) &&
|
|
31
|
+
(o.lastUsedAt === undefined || typeof o.lastUsedAt === 'string');
|
|
32
|
+
}
|
|
33
|
+
class FileCredentialsProvider extends BaseCredentialsProvider {
|
|
34
|
+
file;
|
|
35
|
+
key;
|
|
36
|
+
providerName = 'FileCredentialsProvider';
|
|
37
|
+
providerDescription = 'Signed JSON credentials store beside the user store';
|
|
38
|
+
rows = new Map();
|
|
39
|
+
/** #1633: rows that failed verification, exactly as read, written back unchanged. */
|
|
40
|
+
quarantine = [];
|
|
41
|
+
/**
|
|
42
|
+
* @param file the store; created on first write
|
|
43
|
+
* @param key `NGDPBASE_CREDENTIALS_KEY`; refused when empty
|
|
44
|
+
*/
|
|
45
|
+
constructor(file, key) {
|
|
46
|
+
super();
|
|
47
|
+
this.file = file;
|
|
48
|
+
this.key = key;
|
|
49
|
+
if (!key)
|
|
50
|
+
throw new Error('The credentials store needs NGDPBASE_CREDENTIALS_KEY (#1524)');
|
|
51
|
+
}
|
|
52
|
+
sign(r) {
|
|
53
|
+
return createHmac('sha256', this.key).update(canonical(r)).digest('base64');
|
|
54
|
+
}
|
|
55
|
+
verifies(r, sig) {
|
|
56
|
+
const expected = Buffer.from(this.sign(r));
|
|
57
|
+
const given = Buffer.from(sig);
|
|
58
|
+
return expected.length === given.length && timingSafeEqual(expected, given);
|
|
59
|
+
}
|
|
60
|
+
async initialize(onRejected) {
|
|
61
|
+
this.rows.clear();
|
|
62
|
+
this.quarantine = [];
|
|
63
|
+
if (!(await fs.pathExists(this.file)))
|
|
64
|
+
return;
|
|
65
|
+
const tightened = secureExistingSecretFile(this.file); // #1560
|
|
66
|
+
if (tightened)
|
|
67
|
+
logger.warn(tightened);
|
|
68
|
+
const parsed = JSON.parse(await fs.readFile(this.file, 'utf8'));
|
|
69
|
+
const rejected = [];
|
|
70
|
+
for (const raw of Array.isArray(parsed.rows) ? parsed.rows : []) {
|
|
71
|
+
const { sig, ...fields } = raw;
|
|
72
|
+
let reason = null;
|
|
73
|
+
if (!isRecord(fields))
|
|
74
|
+
reason = 'malformed';
|
|
75
|
+
else if (typeof sig !== 'string' || sig === '')
|
|
76
|
+
reason = 'unsigned';
|
|
77
|
+
else if (!this.verifies(fields, sig))
|
|
78
|
+
reason = 'bad-signature';
|
|
79
|
+
if (reason) {
|
|
80
|
+
const entry = { row: fields, reason };
|
|
81
|
+
rejected.push(entry);
|
|
82
|
+
this.quarantine.push({ raw: raw, rejected: entry });
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
this.rows.set(fields.id, fields);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (rejected.length > 0)
|
|
89
|
+
onRejected(rejected);
|
|
90
|
+
}
|
|
91
|
+
list(username) {
|
|
92
|
+
return [...this.rows.values()]
|
|
93
|
+
.filter(r => r.username === username)
|
|
94
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
95
|
+
}
|
|
96
|
+
listKind(kind) {
|
|
97
|
+
return [...this.rows.values()]
|
|
98
|
+
.filter(r => r.kind === kind)
|
|
99
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
100
|
+
}
|
|
101
|
+
get(id) {
|
|
102
|
+
return this.rows.get(id) ?? null;
|
|
103
|
+
}
|
|
104
|
+
async add(record) {
|
|
105
|
+
if (!isRecord(record))
|
|
106
|
+
throw new Error('Not a credential record (#1524)');
|
|
107
|
+
if (this.rows.has(record.id))
|
|
108
|
+
throw new Error(`Credential ${record.id} already exists`);
|
|
109
|
+
for (const r of this.rows.values()) {
|
|
110
|
+
if (r.kind === record.kind && r.subject === record.subject) {
|
|
111
|
+
throw new Error(`A ${record.kind} credential with that subject is already enrolled`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
this.rows.set(record.id, { ...record });
|
|
115
|
+
await this.save();
|
|
116
|
+
}
|
|
117
|
+
async remove(id) {
|
|
118
|
+
if (!this.rows.delete(id))
|
|
119
|
+
return false;
|
|
120
|
+
await this.save();
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
findBySubject(kind, subject) {
|
|
124
|
+
for (const r of this.rows.values())
|
|
125
|
+
if (r.kind === kind && r.subject === subject)
|
|
126
|
+
return r;
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
async touch(id, at, secret) {
|
|
130
|
+
const r = this.rows.get(id);
|
|
131
|
+
if (!r)
|
|
132
|
+
return;
|
|
133
|
+
r.lastUsedAt = at;
|
|
134
|
+
if (secret !== undefined)
|
|
135
|
+
r.secret = secret;
|
|
136
|
+
await this.save();
|
|
137
|
+
}
|
|
138
|
+
async relabel(id, label) {
|
|
139
|
+
const r = this.rows.get(id);
|
|
140
|
+
if (!r)
|
|
141
|
+
return false;
|
|
142
|
+
r.label = label;
|
|
143
|
+
await this.save();
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
location() {
|
|
147
|
+
return this.file;
|
|
148
|
+
}
|
|
149
|
+
quarantined() {
|
|
150
|
+
return this.quarantine.map(q => q.rejected);
|
|
151
|
+
}
|
|
152
|
+
async retrustQuarantined() {
|
|
153
|
+
const trusted = [];
|
|
154
|
+
const kept = [];
|
|
155
|
+
for (const q of this.quarantine) {
|
|
156
|
+
const { sig: _sig, ...fields } = q.raw;
|
|
157
|
+
const clash = isRecord(fields) && (this.rows.has(fields.id) ||
|
|
158
|
+
[...this.rows.values()].some(r => r.kind === fields.kind && r.subject === fields.subject));
|
|
159
|
+
if (!isRecord(fields) || clash) {
|
|
160
|
+
kept.push(q);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
this.rows.set(fields.id, { ...fields });
|
|
164
|
+
trusted.push({ ...fields });
|
|
165
|
+
}
|
|
166
|
+
this.quarantine = kept;
|
|
167
|
+
if (trusted.length > 0)
|
|
168
|
+
await this.save();
|
|
169
|
+
return trusted;
|
|
170
|
+
}
|
|
171
|
+
getDurability() {
|
|
172
|
+
return { bufferedForMs: 0, bufferedRecords: 0, fsync: true };
|
|
173
|
+
}
|
|
174
|
+
async save() {
|
|
175
|
+
const out = {
|
|
176
|
+
version: 1,
|
|
177
|
+
rows: [
|
|
178
|
+
...[...this.rows.values()].map(r => ({ ...r, sig: this.sign(r) })),
|
|
179
|
+
// #1633: quarantined rows go back exactly as they were read — never
|
|
180
|
+
// re-signed here, never dropped — until an operator re-trusts them.
|
|
181
|
+
...this.quarantine.map(q => q.raw)
|
|
182
|
+
]
|
|
183
|
+
};
|
|
184
|
+
await fs.ensureDir(path.dirname(this.file), { mode: 0o700 });
|
|
185
|
+
await writeFileAtomic(this.file, JSON.stringify(out, null, 2), 'utf8', { fsync: true, mode: 0o600 });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
export default FileCredentialsProvider;
|
|
@@ -221,6 +221,12 @@ declare class FileSystemMediaProvider extends BaseMediaProvider {
|
|
|
221
221
|
* Patterns ending with `/` are directory-only; all others match both files and dirs.
|
|
222
222
|
*/
|
|
223
223
|
private matchesIgnorePattern;
|
|
224
|
+
/**
|
|
225
|
+
* Add a file to a capture-date list until CAPTURE_DATE_LIST_CAP (#2191).
|
|
226
|
+
* The path is relative to the media folder it was found under, so the
|
|
227
|
+
* notice and its email do not disclose the server's storage layout.
|
|
228
|
+
*/
|
|
229
|
+
private listCaptureDateFile;
|
|
224
230
|
/**
|
|
225
231
|
* Process a single media file: stat, EXIF read, index update.
|
|
226
232
|
*/
|