@rndmjoker/proton-mcp 0.4.13 → 0.5.1
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 +138 -21
- package/dist/browser/channel.d.ts +76 -0
- package/dist/browser/channel.js +162 -0
- package/dist/browser/channel.js.map +1 -0
- package/dist/browser/client.d.ts +50 -0
- package/dist/browser/client.js +284 -0
- package/dist/browser/client.js.map +1 -0
- package/dist/browser/connector.d.ts +15 -0
- package/dist/browser/connector.js +26 -0
- package/dist/browser/connector.js.map +1 -0
- package/dist/browser/host.d.ts +50 -0
- package/dist/browser/host.js +258 -0
- package/dist/browser/host.js.map +1 -0
- package/dist/browser/messages.d.ts +163 -0
- package/dist/browser/messages.js +338 -0
- package/dist/browser/messages.js.map +1 -0
- package/dist/browser/paths.d.ts +51 -0
- package/dist/browser/paths.js +92 -0
- package/dist/browser/paths.js.map +1 -0
- package/dist/browser/setup.d.ts +42 -0
- package/dist/browser/setup.js +170 -0
- package/dist/browser/setup.js.map +1 -0
- package/dist/filters/order.d.ts +66 -0
- package/dist/filters/order.js +108 -0
- package/dist/filters/order.js.map +1 -0
- package/dist/filters/sieve.d.ts +184 -0
- package/dist/filters/sieve.js +1312 -0
- package/dist/filters/sieve.js.map +1 -0
- package/dist/instances.d.ts +58 -0
- package/dist/instances.js +145 -0
- package/dist/instances.js.map +1 -0
- package/dist/mail/compose.d.ts +10 -0
- package/dist/mail/compose.js.map +1 -1
- package/dist/mail/record.d.ts +36 -0
- package/dist/mail/record.js +74 -0
- package/dist/mail/record.js.map +1 -0
- package/dist/permissions.d.ts +139 -0
- package/dist/permissions.js +355 -0
- package/dist/permissions.js.map +1 -0
- package/dist/server.js +330 -16
- package/dist/server.js.map +1 -1
- package/dist/session.d.ts +12 -0
- package/dist/session.js +23 -0
- package/dist/session.js.map +1 -1
- package/dist/settings.d.ts +81 -11
- package/dist/settings.js +160 -25
- package/dist/settings.js.map +1 -1
- package/dist/tools/confirm.d.ts +4 -2
- package/dist/tools/confirm.js +12 -3
- package/dist/tools/confirm.js.map +1 -1
- package/dist/tools/failures.d.ts +6 -8
- package/dist/tools/failures.js +36 -17
- package/dist/tools/failures.js.map +1 -1
- package/dist/tools/filter-tools.d.ts +82 -0
- package/dist/tools/filter-tools.js +729 -0
- package/dist/tools/filter-tools.js.map +1 -0
- package/dist/tools/filters.d.ts +8 -1
- package/dist/tools/filters.js +24 -12
- package/dist/tools/filters.js.map +1 -1
- package/dist/tools/format.d.ts +15 -0
- package/dist/tools/format.js +4 -3
- package/dist/tools/format.js.map +1 -1
- package/dist/tools/guidance.js +12 -7
- package/dist/tools/guidance.js.map +1 -1
- package/dist/tools/interface.d.ts +20 -6
- package/dist/tools/interface.js +40 -16
- package/dist/tools/interface.js.map +1 -1
- package/dist/tools/send.d.ts +9 -0
- package/dist/tools/send.js +51 -9
- package/dist/tools/send.js.map +1 -1
- package/dist/tools/sign-in-required.d.ts +15 -2
- package/dist/tools/sign-in-required.js +30 -2
- package/dist/tools/sign-in-required.js.map +1 -1
- package/dist/web/layout.d.ts +12 -5
- package/dist/web/layout.js +32 -9
- package/dist/web/layout.js.map +1 -1
- package/dist/web/pages.d.ts +4 -2
- package/dist/web/pages.js +10 -9
- package/dist/web/pages.js.map +1 -1
- package/dist/web/pending.d.ts +9 -6
- package/dist/web/pending.js +9 -7
- package/dist/web/pending.js.map +1 -1
- package/dist/web/preview-server.d.ts +33 -0
- package/dist/web/preview-server.js +102 -0
- package/dist/web/preview-server.js.map +1 -0
- package/dist/web/sections.d.ts +86 -1
- package/dist/web/sections.js +290 -7
- package/dist/web/sections.js.map +1 -1
- package/dist/web/security.d.ts +48 -13
- package/dist/web/security.js +68 -16
- package/dist/web/security.js.map +1 -1
- package/dist/web/server.d.ts +81 -24
- package/dist/web/server.js +478 -117
- package/dist/web/server.js.map +1 -1
- package/package.json +1 -1
package/dist/web/server.js
CHANGED
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
* for what each one defends against.
|
|
12
12
|
*/
|
|
13
13
|
var _a;
|
|
14
|
-
import { createServer } from 'node:http';
|
|
14
|
+
import { createServer, request as httpRequest, } from 'node:http';
|
|
15
15
|
import { STORE_DESCRIPTIONS } from '../credentials/store.js';
|
|
16
16
|
import { listStores, recommend, recommendationReason } from '../credentials/availability.js';
|
|
17
|
-
import { createSession, checkRequest, csrfToken, securityHeaders } from './security.js';
|
|
17
|
+
import { createSession, checkRequest, csrfToken, securityHeaders, hashPin, pinMatches, pinProblem, AttemptLimiter, } from './security.js';
|
|
18
18
|
import { loginPage, rejectionPage, unlockPage } from './pages.js';
|
|
19
|
-
import { overviewSection, bridgeSection, credentialsSection, activitySection, mailboxSection, } from './sections.js';
|
|
20
|
-
import {
|
|
19
|
+
import { overviewSection, bridgeSection, credentialsSection, permissionsSection, activitySection, mailboxSection, } from './sections.js';
|
|
20
|
+
import { UNLOCKABLE } from '../filters/sieve.js';
|
|
21
21
|
/**
|
|
22
22
|
* Upper bound for a request body.
|
|
23
23
|
*
|
|
@@ -39,26 +39,71 @@ const SECTION_PATHS = {
|
|
|
39
39
|
'/mailboxes': 'mailboxes',
|
|
40
40
|
'/bridge': 'bridge',
|
|
41
41
|
'/credentials': 'credentials',
|
|
42
|
+
'/permissions': 'permissions',
|
|
42
43
|
'/activity': 'activity',
|
|
43
44
|
};
|
|
45
|
+
/**
|
|
46
|
+
* Where an answer to each form lands, so a wrong PIN is reported on the page the
|
|
47
|
+
* user was on. Anything not listed lands on the overview.
|
|
48
|
+
*/
|
|
49
|
+
const FORM_SECTIONS = {
|
|
50
|
+
'/settings': 'bridge',
|
|
51
|
+
'/test-connection': 'bridge',
|
|
52
|
+
'/sign-out': 'credentials',
|
|
53
|
+
'/pin': 'permissions',
|
|
54
|
+
'/permissions/levels': 'permissions',
|
|
55
|
+
'/permissions/limits': 'permissions',
|
|
56
|
+
'/permissions/record': 'permissions',
|
|
57
|
+
'/permissions/filter-actions': 'permissions',
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Posts that change nothing worth a PIN. Testing the connection only reads, and
|
|
61
|
+
* the notice about not being Proton's software only hides a paragraph.
|
|
62
|
+
*/
|
|
63
|
+
const WITHOUT_PIN = new Set(['/test-connection', '/dismiss-notice']);
|
|
64
|
+
/**
|
|
65
|
+
* What `/health` answers, so a server that finds the port taken can tell a
|
|
66
|
+
* running proton-mcp from some other program. Says nothing else on purpose.
|
|
67
|
+
*/
|
|
68
|
+
const HEALTH_BODY = JSON.stringify({ app: 'proton-mcp' });
|
|
44
69
|
/** The same names as a set, for checking a section that arrived in a form. */
|
|
45
70
|
const SECTIONS = new Set(Object.values(SECTION_PATHS));
|
|
46
71
|
/**
|
|
47
|
-
* The
|
|
72
|
+
* The one address of the interface, shared by every running server.
|
|
48
73
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
74
|
+
* Fixed rather than random, and since 2026-11-05 also not walked upwards when
|
|
75
|
+
* taken: every client session starts its own server, and the interface they
|
|
76
|
+
* share has to be at the same address for all of them. The first server to
|
|
77
|
+
* start serves it; the others take over when it stops.
|
|
52
78
|
*/
|
|
53
79
|
export const DEFAULT_WEB_PORT = 7345;
|
|
80
|
+
/** How often a server that does not serve the interface tries to take it over. */
|
|
81
|
+
export const TAKEOVER_INTERVAL_MS = 10_000;
|
|
82
|
+
/**
|
|
83
|
+
* How much of an answer the probe reads: the health answer of a current
|
|
84
|
+
* proton-mcp, and the head of an older one's page, whose title comes within
|
|
85
|
+
* the first 300 bytes.
|
|
86
|
+
*/
|
|
87
|
+
const PROBE_BYTES = 1024;
|
|
54
88
|
/**
|
|
55
|
-
*
|
|
89
|
+
* Names the occupant from the start of its answer to /health.
|
|
56
90
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
91
|
+
* A current proton-mcp answers with a small JSON object. An older one has no
|
|
92
|
+
* such address and answers every request without its token with a page of its
|
|
93
|
+
* own, titled "Not available - proton-mcp": measured with 0.4.13, and the
|
|
94
|
+
* title has had this form since 0.3.0. Anything else is another program.
|
|
60
95
|
*/
|
|
61
|
-
|
|
96
|
+
function classifyOccupant(start) {
|
|
97
|
+
try {
|
|
98
|
+
const body = JSON.parse(start);
|
|
99
|
+
if (body.app === 'proton-mcp')
|
|
100
|
+
return 'proton-mcp';
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Not JSON, so not a current proton-mcp. Perhaps an older one.
|
|
104
|
+
}
|
|
105
|
+
return /<title>[^<]* - proton-mcp<\/title>/.test(start) ? 'older' : 'other';
|
|
106
|
+
}
|
|
62
107
|
/**
|
|
63
108
|
* Reads a form body, refusing anything oversized.
|
|
64
109
|
*
|
|
@@ -105,88 +150,176 @@ export class WebInterface {
|
|
|
105
150
|
#server;
|
|
106
151
|
#secrets = createSession();
|
|
107
152
|
#port = 0;
|
|
153
|
+
/** Who held the port when this server last found it taken. */
|
|
154
|
+
#occupant = 'none';
|
|
155
|
+
#takeover;
|
|
156
|
+
#limiter = new AttemptLimiter();
|
|
108
157
|
#options;
|
|
109
158
|
#notify;
|
|
110
159
|
constructor(options) {
|
|
111
160
|
this.#options = options;
|
|
112
161
|
this.#notify = options.notify ?? ((m) => process.stderr.write(`proton-mcp: ${m}\n`));
|
|
113
162
|
}
|
|
114
|
-
/**
|
|
163
|
+
/**
|
|
164
|
+
* The address of the interface. The same for every running server, whichever
|
|
165
|
+
* of them serves it, and nothing in it is secret.
|
|
166
|
+
*/
|
|
115
167
|
get url() {
|
|
116
|
-
|
|
168
|
+
const port = this.#port || this.#options.port || DEFAULT_WEB_PORT;
|
|
169
|
+
return `http://${BIND_ADDRESS}:${port}/`;
|
|
117
170
|
}
|
|
118
171
|
get port() {
|
|
119
172
|
return this.#port;
|
|
120
173
|
}
|
|
174
|
+
/** True while this server serves the interface itself. */
|
|
121
175
|
get running() {
|
|
122
176
|
return this.#server?.listening === true;
|
|
123
177
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
178
|
+
/**
|
|
179
|
+
* Whether the interface can be reached at `url`, from this server or from
|
|
180
|
+
* another running proton-mcp. False when something else holds the port.
|
|
181
|
+
*/
|
|
182
|
+
get reachable() {
|
|
183
|
+
return this.running || this.#occupant === 'proton-mcp';
|
|
184
|
+
}
|
|
185
|
+
/** Who held the port when this server last found it taken. */
|
|
186
|
+
get occupant() {
|
|
187
|
+
return this.running ? 'none' : this.#occupant;
|
|
188
|
+
}
|
|
189
|
+
#build() {
|
|
190
|
+
const created = createServer((request, response) => {
|
|
191
|
+
void this.#handle(request, response).catch(() => {
|
|
192
|
+
// A handler that throws must not take the process down, and must not
|
|
193
|
+
// leak anything either.
|
|
194
|
+
this.#send(response, 500, 'text/plain; charset=utf-8', 'Internal error.');
|
|
134
195
|
});
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
attempt.once('error', onError);
|
|
154
|
-
// Explicitly loopback: binding to 0.0.0.0 would put a page that stores
|
|
155
|
-
// credentials onto the network.
|
|
156
|
-
attempt.listen(candidate, BIND_ADDRESS, () => {
|
|
157
|
-
attempt.removeListener('error', onError);
|
|
158
|
-
resolve();
|
|
159
|
-
});
|
|
196
|
+
});
|
|
197
|
+
// Slow clients must not be able to tie up the server indefinitely.
|
|
198
|
+
created.headersTimeout = 10_000;
|
|
199
|
+
created.requestTimeout = 30_000;
|
|
200
|
+
return created;
|
|
201
|
+
}
|
|
202
|
+
/** One attempt at the port. Resolves with the error instead of throwing. */
|
|
203
|
+
async #listen(port) {
|
|
204
|
+
const attempt = this.#build();
|
|
205
|
+
try {
|
|
206
|
+
await new Promise((resolve, reject) => {
|
|
207
|
+
const onError = (error) => reject(error);
|
|
208
|
+
attempt.once('error', onError);
|
|
209
|
+
// Explicitly loopback: binding to 0.0.0.0 would put a page that stores
|
|
210
|
+
// credentials onto the network.
|
|
211
|
+
attempt.listen(port, BIND_ADDRESS, () => {
|
|
212
|
+
attempt.removeListener('error', onError);
|
|
213
|
+
resolve();
|
|
160
214
|
});
|
|
161
|
-
|
|
162
|
-
lastError = undefined;
|
|
163
|
-
break;
|
|
164
|
-
}
|
|
165
|
-
catch (error) {
|
|
166
|
-
attempt.close();
|
|
167
|
-
lastError = error;
|
|
168
|
-
if (error.code !== 'EADDRINUSE')
|
|
169
|
-
break;
|
|
170
|
-
// Taken, most likely by another instance of this server. Try the next.
|
|
171
|
-
}
|
|
215
|
+
});
|
|
172
216
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
? `Ports ${wanted} to ${wanted + attempts - 1} are all in use, so the web interface ` +
|
|
177
|
-
'could not start. Set PROTON_MCP_WEB_PORT to a free port.'
|
|
178
|
-
: `The web interface could not start: ${lastError?.message ?? 'unknown reason'}`);
|
|
217
|
+
catch (error) {
|
|
218
|
+
attempt.close();
|
|
219
|
+
return error;
|
|
179
220
|
}
|
|
180
|
-
const address =
|
|
221
|
+
const address = attempt.address();
|
|
181
222
|
this.#port = typeof address === 'object' && address ? address.port : 0;
|
|
182
|
-
this.#server =
|
|
223
|
+
this.#server = attempt;
|
|
224
|
+
this.#occupant = 'none';
|
|
225
|
+
this.#options.onServingChange?.(true);
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Asks whoever holds the port whether it is a proton-mcp.
|
|
230
|
+
*
|
|
231
|
+
* The answer decides between "another session serves the interface, all is
|
|
232
|
+
* well" and "something else sits on this port", which the user has to hear
|
|
233
|
+
* about, because then nobody serves the interface at all. An older proton-mcp
|
|
234
|
+
* is the third answer: no interface either, but the remedy is to restart the
|
|
235
|
+
* sessions that still run it, not to move every server to another port.
|
|
236
|
+
*/
|
|
237
|
+
static async probe(port, timeoutMs = 1500) {
|
|
238
|
+
return new Promise((resolve) => {
|
|
239
|
+
const request = httpRequest({ host: BIND_ADDRESS, port, path: '/health', method: 'GET', timeout: timeoutMs }, (response) => {
|
|
240
|
+
// The start of the answer, however long the whole is. Collecting
|
|
241
|
+
// only answers that were short in total missed an older proton-mcp,
|
|
242
|
+
// whose page runs to a few kilobytes and arrives in one piece.
|
|
243
|
+
const chunks = [];
|
|
244
|
+
let kept = 0;
|
|
245
|
+
response.on('data', (chunk) => {
|
|
246
|
+
if (kept >= PROBE_BYTES)
|
|
247
|
+
return;
|
|
248
|
+
const part = chunk.subarray(0, PROBE_BYTES - kept);
|
|
249
|
+
chunks.push(part);
|
|
250
|
+
kept += part.length;
|
|
251
|
+
});
|
|
252
|
+
response.on('end', () => resolve(classifyOccupant(Buffer.concat(chunks).toString('utf8'))));
|
|
253
|
+
});
|
|
254
|
+
request.on('timeout', () => request.destroy());
|
|
255
|
+
request.on('error', (error) => {
|
|
256
|
+
resolve(error.code === 'ECONNREFUSED' ? 'none' : 'other');
|
|
257
|
+
});
|
|
258
|
+
request.end();
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Serves the interface, or leaves it to the server that already does.
|
|
263
|
+
*
|
|
264
|
+
* Never throws over a taken port. Another proton-mcp on it is the ordinary
|
|
265
|
+
* case with several sessions; this server then tries every few seconds to
|
|
266
|
+
* take over, so the interface survives the session that started it. Anything
|
|
267
|
+
* else on the port is reported once, and taken over too if it ever leaves.
|
|
268
|
+
*/
|
|
269
|
+
async start() {
|
|
270
|
+
if (this.running)
|
|
271
|
+
return this.url;
|
|
272
|
+
const wanted = this.#options.port ?? DEFAULT_WEB_PORT;
|
|
273
|
+
const failure = await this.#listen(wanted);
|
|
274
|
+
if (!failure)
|
|
275
|
+
return this.url;
|
|
276
|
+
if (failure.code !== 'EADDRINUSE' || wanted === 0) {
|
|
277
|
+
throw new Error(`The web interface could not start: ${failure.message}`);
|
|
278
|
+
}
|
|
279
|
+
this.#occupant = await _a.probe(wanted);
|
|
280
|
+
if (this.#occupant === 'other') {
|
|
281
|
+
this.#notify(`port ${wanted} is held by a program that is not proton-mcp, so the web interface is not ` +
|
|
282
|
+
'available. Set PROTON_MCP_WEB_PORT to a free port for every proton-mcp, or stop that ' +
|
|
283
|
+
'program. This server takes the port as soon as it is free.');
|
|
284
|
+
}
|
|
285
|
+
else if (this.#occupant === 'older') {
|
|
286
|
+
this.#notify(`port ${wanted} is held by an older proton-mcp, from a session started before the update. ` +
|
|
287
|
+
'It serves an interface of its own and knows nothing of the shared one, so the web ' +
|
|
288
|
+
'interface is not available until that session ends. This server takes the port over ' +
|
|
289
|
+
'then; restarting the sessions that still run the older version makes that happen now.');
|
|
290
|
+
}
|
|
291
|
+
this.#scheduleTakeover(wanted);
|
|
183
292
|
return this.url;
|
|
184
293
|
}
|
|
294
|
+
#scheduleTakeover(port) {
|
|
295
|
+
if (this.#takeover)
|
|
296
|
+
return;
|
|
297
|
+
this.#takeover = setInterval(() => {
|
|
298
|
+
void (async () => {
|
|
299
|
+
if (this.running)
|
|
300
|
+
return;
|
|
301
|
+
if (await this.#listen(port)) {
|
|
302
|
+
// Still taken. Find out by whom, so the status stays honest.
|
|
303
|
+
this.#occupant = await _a.probe(port);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
clearInterval(this.#takeover);
|
|
307
|
+
this.#takeover = undefined;
|
|
308
|
+
this.#notify(`took over the web interface at ${this.url}`);
|
|
309
|
+
})();
|
|
310
|
+
}, this.#options.takeoverIntervalMs ?? TAKEOVER_INTERVAL_MS);
|
|
311
|
+
// Waiting to take over must not keep the process alive on its own.
|
|
312
|
+
this.#takeover.unref();
|
|
313
|
+
}
|
|
185
314
|
async stop() {
|
|
315
|
+
if (this.#takeover)
|
|
316
|
+
clearInterval(this.#takeover);
|
|
317
|
+
this.#takeover = undefined;
|
|
186
318
|
const server = this.#server;
|
|
187
319
|
this.#server = undefined;
|
|
188
320
|
if (!server)
|
|
189
321
|
return;
|
|
322
|
+
this.#options.onServingChange?.(false);
|
|
190
323
|
// close() alone only stops accepting new connections and then waits for the
|
|
191
324
|
// existing ones. A browser keeps its connection alive, so a single open tab
|
|
192
325
|
// would hold the shutdown forever and the MCP server would never exit.
|
|
@@ -194,11 +327,6 @@ export class WebInterface {
|
|
|
194
327
|
server.closeAllConnections();
|
|
195
328
|
await new Promise((resolve) => server.close(() => resolve()));
|
|
196
329
|
}
|
|
197
|
-
/** Invalidates the current link and issues a new one. */
|
|
198
|
-
rotateToken() {
|
|
199
|
-
this.#secrets = createSession();
|
|
200
|
-
return this.url;
|
|
201
|
-
}
|
|
202
330
|
#send(response, status, contentType, body) {
|
|
203
331
|
response.writeHead(status, { ...securityHeaders(), 'Content-Type': contentType });
|
|
204
332
|
response.end(body);
|
|
@@ -217,9 +345,9 @@ export class WebInterface {
|
|
|
217
345
|
this.#reject(response, 405);
|
|
218
346
|
return;
|
|
219
347
|
}
|
|
220
|
-
// Answered before the
|
|
221
|
-
//
|
|
222
|
-
//
|
|
348
|
+
// Answered before the checks and without a log line. Browsers ask for it on
|
|
349
|
+
// every page view, and each one would otherwise be recorded as a refused
|
|
350
|
+
// request, which buries the refusals that mean something.
|
|
223
351
|
if (path === '/favicon.ico') {
|
|
224
352
|
response.writeHead(404, { ...securityHeaders(), 'Content-Length': '0' });
|
|
225
353
|
response.end();
|
|
@@ -240,7 +368,6 @@ export class WebInterface {
|
|
|
240
368
|
request,
|
|
241
369
|
secrets: this.#secrets,
|
|
242
370
|
port: this.#port,
|
|
243
|
-
presentedToken: url.searchParams.get('token') ?? undefined,
|
|
244
371
|
writing,
|
|
245
372
|
...(writing ? { csrf: { purpose, presented: body.fields.csrf } } : {}),
|
|
246
373
|
});
|
|
@@ -252,19 +379,49 @@ export class WebInterface {
|
|
|
252
379
|
return;
|
|
253
380
|
}
|
|
254
381
|
if (method === 'GET') {
|
|
382
|
+
if (path === '/health') {
|
|
383
|
+
response.writeHead(200, { ...securityHeaders(), 'Content-Type': 'application/json' });
|
|
384
|
+
response.end(HEALTH_BODY);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
255
387
|
const section = SECTION_PATHS[path];
|
|
256
388
|
if (section) {
|
|
257
389
|
await this.#render(section, response);
|
|
258
390
|
return;
|
|
259
391
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
392
|
+
}
|
|
393
|
+
// The PIN, for every change. A wrong one is the user's mistake and gets a
|
|
394
|
+
// page that says so, unlike the refusals above, which say nothing.
|
|
395
|
+
if (writing && !WITHOUT_PIN.has(path)) {
|
|
396
|
+
const refusal = await this.#checkPin(body.fields.pin);
|
|
397
|
+
if (refusal) {
|
|
398
|
+
await this.#render(FORM_SECTIONS[path] ?? 'overview', response, {
|
|
399
|
+
error: refusal,
|
|
400
|
+
...(path === '/sign-in' && body.fields.address ? { address: body.fields.address } : {}),
|
|
401
|
+
});
|
|
265
402
|
return;
|
|
266
403
|
}
|
|
267
404
|
}
|
|
405
|
+
if (writing && path === '/pin') {
|
|
406
|
+
await this.#handlePin(response, body.fields);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (writing && path === '/permissions/levels') {
|
|
410
|
+
await this.#handleLevels(response, body.fields);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (writing && path === '/permissions/limits') {
|
|
414
|
+
await this.#handleLimits(response, body.fields);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (writing && path === '/permissions/record') {
|
|
418
|
+
await this.#handleRecord(response, body.fields);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (writing && path === '/permissions/filter-actions') {
|
|
422
|
+
await this.#handleFilterActions(response, body.fields);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
268
425
|
if (writing && path === '/dismiss-notice') {
|
|
269
426
|
await this.#handleDismissNotice(response, body.fields);
|
|
270
427
|
return;
|
|
@@ -313,11 +470,11 @@ export class WebInterface {
|
|
|
313
470
|
async #render(section, response, outcome = {}) {
|
|
314
471
|
const { error, notice, address, store, showAdvanced } = outcome;
|
|
315
472
|
const status = await this.#options.getStatus();
|
|
316
|
-
const
|
|
473
|
+
const pinRequired = this.#options.getPin?.() !== undefined;
|
|
317
474
|
const disclaimer = this.#disclaimerFor(section);
|
|
318
475
|
if (!status.connected && status.locked) {
|
|
319
476
|
const page = unlockPage({
|
|
320
|
-
|
|
477
|
+
pinRequired,
|
|
321
478
|
disclaimer,
|
|
322
479
|
csrf: csrfToken(this.#secrets, '/unlock'),
|
|
323
480
|
csrfDiscard: csrfToken(this.#secrets, '/discard-encrypted'),
|
|
@@ -330,7 +487,7 @@ export class WebInterface {
|
|
|
330
487
|
if (!status.connected) {
|
|
331
488
|
const stores = await listStores();
|
|
332
489
|
const page = loginPage({
|
|
333
|
-
|
|
490
|
+
pinRequired,
|
|
334
491
|
disclaimer,
|
|
335
492
|
csrf: csrfToken(this.#secrets, '/sign-in'),
|
|
336
493
|
stores,
|
|
@@ -350,6 +507,7 @@ export class WebInterface {
|
|
|
350
507
|
}
|
|
351
508
|
const banners = {
|
|
352
509
|
disclaimer,
|
|
510
|
+
pinRequired,
|
|
353
511
|
...(error ? { error } : {}),
|
|
354
512
|
...(notice ? { notice } : {}),
|
|
355
513
|
};
|
|
@@ -359,11 +517,10 @@ export class WebInterface {
|
|
|
359
517
|
let page;
|
|
360
518
|
switch (section) {
|
|
361
519
|
case 'mailboxes':
|
|
362
|
-
page = await this.#renderMailboxes(
|
|
520
|
+
page = await this.#renderMailboxes(banners);
|
|
363
521
|
break;
|
|
364
522
|
case 'bridge':
|
|
365
523
|
page = bridgeSection({
|
|
366
|
-
token,
|
|
367
524
|
csrfSettings: csrfToken(this.#secrets, '/settings'),
|
|
368
525
|
csrfTest: csrfToken(this.#secrets, '/test-connection'),
|
|
369
526
|
bridgeHost: status.bridgeHost,
|
|
@@ -377,7 +534,6 @@ export class WebInterface {
|
|
|
377
534
|
break;
|
|
378
535
|
case 'credentials':
|
|
379
536
|
page = credentialsSection({
|
|
380
|
-
token,
|
|
381
537
|
csrfSignOut: csrfToken(this.#secrets, '/sign-out'),
|
|
382
538
|
...(status.address ? { address: status.address } : {}),
|
|
383
539
|
...(status.storeKind ? { storeKind: status.storeKind } : {}),
|
|
@@ -385,15 +541,46 @@ export class WebInterface {
|
|
|
385
541
|
...banners,
|
|
386
542
|
});
|
|
387
543
|
break;
|
|
544
|
+
case 'permissions': {
|
|
545
|
+
const permissions = this.#options.getPermissions?.();
|
|
546
|
+
page = permissionsSection({
|
|
547
|
+
csrfPin: csrfToken(this.#secrets, '/pin'),
|
|
548
|
+
csrfLevels: csrfToken(this.#secrets, '/permissions/levels'),
|
|
549
|
+
csrfLimits: csrfToken(this.#secrets, '/permissions/limits'),
|
|
550
|
+
csrfRecord: csrfToken(this.#secrets, '/permissions/record'),
|
|
551
|
+
csrfFilterActions: csrfToken(this.#secrets, '/permissions/filter-actions'),
|
|
552
|
+
...(permissions ? { tools: permissions.tools, limits: permissions.limits } : {}),
|
|
553
|
+
...(this.#options.getRecord ? { record: this.#options.getRecord() } : {}),
|
|
554
|
+
...(this.#options.getFilterActions ? { filterActions: this.#options.getFilterActions() } : {}),
|
|
555
|
+
...banners,
|
|
556
|
+
});
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
388
559
|
case 'activity': {
|
|
389
560
|
const activity = this.#options.getActivity?.() ?? { running: [], recent: [] };
|
|
390
|
-
page = activitySection({
|
|
561
|
+
page = activitySection({ ...activity, ...banners });
|
|
391
562
|
break;
|
|
392
563
|
}
|
|
393
564
|
default:
|
|
394
565
|
page = overviewSection({
|
|
395
|
-
token,
|
|
396
566
|
connected: true,
|
|
567
|
+
...(this.#options.getServers ? { servers: this.#options.getServers() } : {}),
|
|
568
|
+
...(() => {
|
|
569
|
+
const record = this.#options.getRecord?.();
|
|
570
|
+
return record?.address ? { record: { address: record.address, mode: record.mode ?? 'bcc' } } : {};
|
|
571
|
+
})(),
|
|
572
|
+
...(this.#options.getFilterActions ? { filterActions: this.#options.getFilterActions().allowed } : {}),
|
|
573
|
+
...(this.#options.getPermissions
|
|
574
|
+
? {
|
|
575
|
+
departures: this.#options
|
|
576
|
+
.getPermissions()
|
|
577
|
+
.tools.filter((tool) => tool.departs)
|
|
578
|
+
.map((tool) => ({
|
|
579
|
+
tool: tool.tool,
|
|
580
|
+
level: { off: 'off', ask: 'ask first', act: 'act on its own' }[tool.level],
|
|
581
|
+
})),
|
|
582
|
+
}
|
|
583
|
+
: {}),
|
|
397
584
|
...(status.address ? { address: status.address } : {}),
|
|
398
585
|
...storeTitle,
|
|
399
586
|
...(status.mailboxCount !== undefined ? { mailboxCount: status.mailboxCount } : {}),
|
|
@@ -411,35 +598,209 @@ export class WebInterface {
|
|
|
411
598
|
this.#send(response, 200, 'text/html; charset=utf-8', page);
|
|
412
599
|
}
|
|
413
600
|
/**
|
|
414
|
-
*
|
|
601
|
+
* Checks the PIN of a change. Returns why it was refused, or nothing.
|
|
415
602
|
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
|
|
603
|
+
* No PIN set means nothing to check: then anything on this machine can make
|
|
604
|
+
* changes, which the pages say. Too many wrong attempts lock out every attempt
|
|
605
|
+
* for a while, the right one included, so the lock cannot be used to tell
|
|
606
|
+
* when a guess was right.
|
|
607
|
+
*/
|
|
608
|
+
async #checkPin(presented) {
|
|
609
|
+
const stored = this.#options.getPin?.();
|
|
610
|
+
if (!stored)
|
|
611
|
+
return undefined;
|
|
612
|
+
if (!this.#limiter.allowed()) {
|
|
613
|
+
return 'Too many wrong PINs. Changes are refused for a minute, whichever PIN is entered.';
|
|
614
|
+
}
|
|
615
|
+
if (presented && (await pinMatches(presented, stored))) {
|
|
616
|
+
this.#limiter.reset();
|
|
617
|
+
return undefined;
|
|
618
|
+
}
|
|
619
|
+
this.#limiter.fail();
|
|
620
|
+
this.#notify('web interface refused a change with a wrong or missing PIN');
|
|
621
|
+
return presented ? 'The PIN is not right.' : 'This change needs the PIN.';
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Takes the levels from the form.
|
|
421
625
|
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
626
|
+
* Only tools the page offered, only levels each one offers, and nothing the
|
|
627
|
+
* environment fixed: the form is untrusted input like any other, and a value
|
|
628
|
+
* it should not carry is refused rather than quietly dropped.
|
|
425
629
|
*/
|
|
426
|
-
async #
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
// Nobody signed in means nothing can be waiting, and the sign-in form is
|
|
431
|
-
// the more useful answer than an empty preview.
|
|
432
|
-
if (!status.connected) {
|
|
433
|
-
await this.#render('overview', response);
|
|
630
|
+
async #handleLevels(response, fields) {
|
|
631
|
+
const view = this.#options.getPermissions?.();
|
|
632
|
+
if (!view || !this.#options.onLevels) {
|
|
633
|
+
await this.#render('permissions', response, { error: 'This server cannot store levels.' });
|
|
434
634
|
return;
|
|
435
635
|
}
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
636
|
+
const levels = {};
|
|
637
|
+
for (const tool of view.tools) {
|
|
638
|
+
if (tool.fixed)
|
|
639
|
+
continue;
|
|
640
|
+
const value = fields[`level:${tool.tool}`];
|
|
641
|
+
if (value === undefined)
|
|
642
|
+
continue;
|
|
643
|
+
if (!tool.options.includes(value)) {
|
|
644
|
+
await this.#render('permissions', response, {
|
|
645
|
+
error: `"${value}" is not a level ${tool.tool} offers.`,
|
|
646
|
+
});
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
levels[tool.tool] = value;
|
|
650
|
+
}
|
|
651
|
+
const failure = await this.#options.onLevels(levels);
|
|
652
|
+
await this.#render('permissions', response, failure ? { error: failure } : { notice: 'The levels are saved.' });
|
|
653
|
+
}
|
|
654
|
+
/** Takes the bounds on sending without asking from the form, each one checked. */
|
|
655
|
+
async #handleLimits(response, fields) {
|
|
656
|
+
if (!this.#options.onLimits) {
|
|
657
|
+
await this.#render('permissions', response, { error: 'This server cannot store bounds.' });
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
const limits = {};
|
|
661
|
+
const allowed = (fields.allowed ?? '')
|
|
662
|
+
.split(/[\s,]+/)
|
|
663
|
+
.map((entry) => entry.trim())
|
|
664
|
+
.filter(Boolean);
|
|
665
|
+
const malformed = allowed.find((entry) => !/^[^\s@]*@[^\s@]+\.[^\s@]+$/.test(entry));
|
|
666
|
+
if (malformed) {
|
|
667
|
+
await this.#render('permissions', response, {
|
|
668
|
+
error: `"${malformed}" is neither an address nor a domain written as @example.com.`,
|
|
669
|
+
});
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
if (allowed.length > 0)
|
|
673
|
+
limits.allowed = allowed;
|
|
674
|
+
const max = (fields.maxPerHour ?? '').trim();
|
|
675
|
+
if (max) {
|
|
676
|
+
const value = Number(max);
|
|
677
|
+
if (!Number.isInteger(value) || value < 1 || value > 10_000) {
|
|
678
|
+
await this.#render('permissions', response, {
|
|
679
|
+
error: `"${max}" is not a usable number of messages per hour.`,
|
|
680
|
+
});
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
limits.maxPerHour = value;
|
|
684
|
+
}
|
|
685
|
+
const until = (fields.until ?? '').trim();
|
|
686
|
+
if (until) {
|
|
687
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(until) || Number.isNaN(Date.parse(`${until}T00:00:00Z`))) {
|
|
688
|
+
await this.#render('permissions', response, { error: `"${until}" is not a day.` });
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
limits.until = until;
|
|
692
|
+
}
|
|
693
|
+
const failure = await this.#options.onLimits(limits);
|
|
694
|
+
await this.#render('permissions', response, failure ? { error: failure } : { notice: 'The bounds are saved.' });
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Takes what filters may do from the form: only the actions the page
|
|
698
|
+
* offers, each only as a ticked box, and nothing the environment fixed.
|
|
699
|
+
*/
|
|
700
|
+
async #handleFilterActions(response, fields) {
|
|
701
|
+
if (!this.#options.onFilterActions || !this.#options.getFilterActions) {
|
|
702
|
+
await this.#render('permissions', response, { error: 'This server cannot store what filters may do.' });
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
if (this.#options.getFilterActions().fixed) {
|
|
706
|
+
await this.#render('permissions', response, {
|
|
707
|
+
error: 'What filters may do is set in the environment (PROTON_MCP_FILTER_ACTIONS), so it is not changed here.',
|
|
708
|
+
});
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
const named = Object.keys(fields)
|
|
712
|
+
.filter((key) => key.startsWith('action:'))
|
|
713
|
+
.map((key) => key.slice('action:'.length));
|
|
714
|
+
const unknown = named.find((name) => !UNLOCKABLE.includes(name));
|
|
715
|
+
if (unknown !== undefined) {
|
|
716
|
+
await this.#render('permissions', response, { error: `"${unknown}" is not an action a filter can be allowed.` });
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
const odd = named.find((name) => fields[`action:${name}`] !== 'on');
|
|
720
|
+
if (odd !== undefined) {
|
|
721
|
+
await this.#render('permissions', response, { error: `The box for ${odd} came back with a value it does not have.` });
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
const failure = await this.#options.onFilterActions(UNLOCKABLE.filter((action) => named.includes(action)));
|
|
725
|
+
await this.#render('permissions', response, failure ? { error: failure } : { notice: 'What filters may do is saved.' });
|
|
726
|
+
}
|
|
727
|
+
/** Takes the copy recipient from the form. An empty address removes it. */
|
|
728
|
+
async #handleRecord(response, fields) {
|
|
729
|
+
if (!this.#options.onRecord) {
|
|
730
|
+
await this.#render('permissions', response, { error: 'This server cannot store a copy recipient.' });
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
// The page shows no form when the environment sets the copy, but a
|
|
734
|
+
// request does not need the page. Storing it anyway would change nothing,
|
|
735
|
+
// since the environment wins, and the notice would claim otherwise.
|
|
736
|
+
if (this.#options.getRecord?.().fixed) {
|
|
737
|
+
await this.#render('permissions', response, {
|
|
738
|
+
error: 'The copy is set in the environment (PROTON_MCP_COPY_TO), so it is not changed here.',
|
|
739
|
+
});
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
const address = (fields.address ?? '').trim();
|
|
743
|
+
if (!address) {
|
|
744
|
+
const failure = await this.#options.onRecord(undefined);
|
|
745
|
+
await this.#render('permissions', response, failure ? { error: failure } : { notice: 'No copy is added to messages any more.' });
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
const mode = fields.mode === 'cc' ? 'cc' : 'bcc';
|
|
749
|
+
if (fields.mode !== undefined && fields.mode !== 'cc' && fields.mode !== 'bcc') {
|
|
750
|
+
await this.#render('permissions', response, { error: `"${fields.mode}" is neither cc nor bcc.` });
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
const failure = await this.#options.onRecord({ address, mode });
|
|
754
|
+
await this.#render('permissions', response, failure ? { error: failure } : { notice: `Every message sent now also goes to ${address} as ${mode === 'cc' ? 'Cc' : 'Bcc'}.` });
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Sets, changes or removes the PIN.
|
|
758
|
+
*
|
|
759
|
+
* Changing and removing have passed the PIN check before this runs, like
|
|
760
|
+
* every other change. Setting one where none exists needs nothing, because
|
|
761
|
+
* until then anything could have changed anything anyway.
|
|
762
|
+
*/
|
|
763
|
+
async #handlePin(response, fields) {
|
|
764
|
+
if (!this.#options.onPinChange) {
|
|
765
|
+
await this.#render('permissions', response, {
|
|
766
|
+
error: 'This server cannot store a PIN.',
|
|
767
|
+
});
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
const action = fields.action;
|
|
771
|
+
const existing = this.#options.getPin?.();
|
|
772
|
+
if (action === 'remove') {
|
|
773
|
+
const failure = await this.#options.onPinChange(undefined);
|
|
774
|
+
await this.#render('permissions', response, failure
|
|
775
|
+
? { error: failure }
|
|
776
|
+
: { notice: 'The PIN is removed. Anything on this machine can change these settings again.' });
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
if (action !== 'set' && action !== 'change') {
|
|
780
|
+
await this.#render('permissions', response);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
if (action === 'set' && existing) {
|
|
784
|
+
await this.#render('permissions', response, {
|
|
785
|
+
error: 'A PIN is set already. Change it with the current one.',
|
|
786
|
+
});
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
const next = fields.next ?? '';
|
|
790
|
+
const problem = pinProblem(next);
|
|
791
|
+
if (problem) {
|
|
792
|
+
await this.#render('permissions', response, { error: problem });
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
if (next !== (fields.nextRepeat ?? '')) {
|
|
796
|
+
await this.#render('permissions', response, { error: 'The two PINs do not match.' });
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
const failure = await this.#options.onPinChange(await hashPin(next));
|
|
800
|
+
this.#limiter.reset();
|
|
801
|
+
await this.#render('permissions', response, failure
|
|
802
|
+
? { error: failure }
|
|
803
|
+
: { notice: action === 'set' ? 'The PIN is set.' : 'The PIN is changed.' });
|
|
443
804
|
}
|
|
444
805
|
/**
|
|
445
806
|
* Whether the notice is shown, and whether it carries a button.
|
|
@@ -475,7 +836,7 @@ export class WebInterface {
|
|
|
475
836
|
* caller has already established that somebody is signed in, so the only
|
|
476
837
|
* question left here is whether the Bridge answers.
|
|
477
838
|
*/
|
|
478
|
-
async #renderMailboxes(
|
|
839
|
+
async #renderMailboxes(banners) {
|
|
479
840
|
let mailboxes = [];
|
|
480
841
|
let error = banners.error;
|
|
481
842
|
if (this.#options.getMailboxes) {
|
|
@@ -487,8 +848,8 @@ export class WebInterface {
|
|
|
487
848
|
}
|
|
488
849
|
}
|
|
489
850
|
return mailboxSection({
|
|
490
|
-
token,
|
|
491
851
|
mailboxes,
|
|
852
|
+
pinRequired: banners.pinRequired,
|
|
492
853
|
disclaimer: banners.disclaimer,
|
|
493
854
|
...(error ? { error } : {}),
|
|
494
855
|
...(banners.notice ? { notice: banners.notice } : {}),
|