dsh-webdav-sync 1.0.0 → 1.0.2
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 +119 -25
- package/lib/client.js +563 -65
- package/lib/dav.d.ts +27 -7
- package/lib/dav.js +134 -28
- package/lib/dirs.d.ts +114 -0
- package/lib/dirs.js +259 -0
- package/lib/engine.d.ts +5 -1
- package/lib/engine.js +207 -44
- package/lib/index.d.ts +7 -18
- package/lib/index.js +98 -40
- package/lib/relay.d.ts +119 -0
- package/lib/relay.js +683 -0
- package/lib/state.d.ts +14 -2
- package/lib/state.js +47 -2
- package/lib/store.d.ts +1 -1
- package/lib/store.js +8 -10
- package/lib/wire.d.ts +228 -17
- package/lib/wire.js +65 -10
- package/package.json +1 -1
package/lib/relay.js
ADDED
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The forwarder for other WebDAV clients on this computer.
|
|
3
|
+
*
|
|
4
|
+
* It is a switch on the sync's own configuration, not a second one. There is
|
|
5
|
+
* nothing here to fill in: the address it forwards to, the account it signs in
|
|
6
|
+
* with and the extra headers it adds are the ones the sync is already configured
|
|
7
|
+
* with, and it reads them per request from the configuration the host is holding.
|
|
8
|
+
* What it adds is a door a local client can come in by — an address on loopback,
|
|
9
|
+
* and an account of its own to knock with, because those clients hold that
|
|
10
|
+
* account and must not be handed the real WebDAV password as well.
|
|
11
|
+
*
|
|
12
|
+
* What it does is a byte pipe for request bodies. It preserves client paths except
|
|
13
|
+
* for the `.prop` compatibility suffix, and reverses only suffixes the relay added
|
|
14
|
+
* when returning PROPFIND hrefs. The `location` header of a redirect is pointed
|
|
15
|
+
* back here too.
|
|
16
|
+
*/
|
|
17
|
+
import { randomBytes } from 'node:crypto';
|
|
18
|
+
import { readFile } from 'node:fs/promises';
|
|
19
|
+
import { join, posix } from 'node:path';
|
|
20
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
21
|
+
import { Transform } from 'node:stream';
|
|
22
|
+
import { writeAtomic } from './store.js';
|
|
23
|
+
import { createServer, request as httpRequest, validateHeaderName, validateHeaderValue, } from 'node:http';
|
|
24
|
+
import { request as httpsRequest } from 'node:https';
|
|
25
|
+
import { sameSecret } from './crypto.js';
|
|
26
|
+
import { parseHeaders } from './dav.js';
|
|
27
|
+
import { defaultConfig } from './state.js';
|
|
28
|
+
import { RELAY_PASSWORD_BYTES, RELAY_USER_BYTES, relayBasePath, relayUrl, } from './wire.js';
|
|
29
|
+
/** How long one forwarded request may take before the pipe is cut. */
|
|
30
|
+
const FORWARD_TIMEOUT_MS = 120_000;
|
|
31
|
+
/**
|
|
32
|
+
* Headers that belong to one connection rather than to the message, so they must
|
|
33
|
+
* not be forwarded in either direction (RFC 7230 §6.1). `host` is dropped too:
|
|
34
|
+
* the target's own host is what the forwarded request must carry.
|
|
35
|
+
*/
|
|
36
|
+
const HOP_BY_HOP = new Set([
|
|
37
|
+
'connection',
|
|
38
|
+
'keep-alive',
|
|
39
|
+
'proxy-authenticate',
|
|
40
|
+
'proxy-authorization',
|
|
41
|
+
'te',
|
|
42
|
+
'trailer',
|
|
43
|
+
'transfer-encoding',
|
|
44
|
+
'upgrade',
|
|
45
|
+
]);
|
|
46
|
+
function connectionHeaders(headers) {
|
|
47
|
+
const skipped = new Set(HOP_BY_HOP);
|
|
48
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
49
|
+
if (name.toLowerCase() !== 'connection' || value === undefined)
|
|
50
|
+
continue;
|
|
51
|
+
for (const token of (Array.isArray(value) ? value.join(',') : value).split(',')) {
|
|
52
|
+
skipped.add(token.trim().toLowerCase());
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return skipped;
|
|
56
|
+
}
|
|
57
|
+
function message(cause) {
|
|
58
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
59
|
+
}
|
|
60
|
+
/** Give extensionless file resources a stable name accepted by backup clients. */
|
|
61
|
+
function withBackupExtension(path) {
|
|
62
|
+
const name = path.slice(path.lastIndexOf('/') + 1);
|
|
63
|
+
let decodedName = name;
|
|
64
|
+
try {
|
|
65
|
+
decodedName = decodeURIComponent(name);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Preserve malformed escapes for the upstream server to handle.
|
|
69
|
+
}
|
|
70
|
+
if (name === '' || /\.(?:zip|prop)$/i.test(decodedName))
|
|
71
|
+
return path;
|
|
72
|
+
return `${path}.prop`;
|
|
73
|
+
}
|
|
74
|
+
function aliasKey(origin, path) {
|
|
75
|
+
return `${origin}${path}`;
|
|
76
|
+
}
|
|
77
|
+
function decodeXml(value) {
|
|
78
|
+
return value.replace(/&(?:amp|lt|gt|quot|apos|#\d+|#x[\da-f]+);/gi, (entity) => {
|
|
79
|
+
if (entity === '&')
|
|
80
|
+
return '&';
|
|
81
|
+
if (entity === '<')
|
|
82
|
+
return '<';
|
|
83
|
+
if (entity === '>')
|
|
84
|
+
return '>';
|
|
85
|
+
if (entity === '"')
|
|
86
|
+
return '"';
|
|
87
|
+
if (entity === ''')
|
|
88
|
+
return "'";
|
|
89
|
+
const hex = entity.match(/^&#x([\da-f]+);$/i);
|
|
90
|
+
const decimal = entity.match(/^&#(\d+);$/);
|
|
91
|
+
const codepoint = hex ? Number.parseInt(hex[1], 16) : decimal ? Number.parseInt(decimal[1], 10) : NaN;
|
|
92
|
+
return Number.isFinite(codepoint) ? String.fromCodePoint(codepoint) : entity;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
function encodeXml(value) {
|
|
96
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
97
|
+
}
|
|
98
|
+
function rewriteHref(value, target, base, aliases) {
|
|
99
|
+
const lead = value.match(/^\s*/)?.[0] ?? '';
|
|
100
|
+
const tail = value.match(/\s*$/)?.[0] ?? '';
|
|
101
|
+
const href = decodeXml(value.slice(lead.length, value.length - tail.length));
|
|
102
|
+
const absolute = /^[a-z][a-z\d+.-]*:/i.test(href);
|
|
103
|
+
const absolutePath = href.startsWith('/');
|
|
104
|
+
let parsed;
|
|
105
|
+
try {
|
|
106
|
+
parsed = new URL(href, base);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
if (parsed.origin !== target.origin)
|
|
112
|
+
return value;
|
|
113
|
+
const originalPath = aliases.get(aliasKey(parsed.origin, parsed.pathname));
|
|
114
|
+
if (originalPath === undefined)
|
|
115
|
+
return value;
|
|
116
|
+
const baseDirectory = base.pathname.endsWith('/') ? base.pathname : base.pathname.slice(0, base.pathname.lastIndexOf('/') + 1);
|
|
117
|
+
const replacementPath = absolute || absolutePath ? originalPath : posix.relative(baseDirectory, originalPath);
|
|
118
|
+
const replacement = `${absolute ? parsed.origin : ''}${replacementPath}${parsed.search}${parsed.hash}`;
|
|
119
|
+
return `${lead}${encodeXml(replacement)}${tail}`;
|
|
120
|
+
}
|
|
121
|
+
function propfindHrefTransform(target, requestPath, aliases) {
|
|
122
|
+
const decoder = new StringDecoder('utf8');
|
|
123
|
+
const base = new URL(requestPath, target);
|
|
124
|
+
let pending = '';
|
|
125
|
+
const openHref = /<(?:[\w.-]+:)?href\b[^>]*>/i;
|
|
126
|
+
const closeHref = /<\/(?:[\w.-]+:)?href\s*>/i;
|
|
127
|
+
const flushPending = (stream, final) => {
|
|
128
|
+
for (;;) {
|
|
129
|
+
const start = pending.search(openHref);
|
|
130
|
+
if (start < 0) {
|
|
131
|
+
if (final) {
|
|
132
|
+
stream.push(pending);
|
|
133
|
+
pending = '';
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const possibleTag = pending.lastIndexOf('<');
|
|
137
|
+
const safeEnd = possibleTag < 0 ? pending.length : possibleTag;
|
|
138
|
+
if (safeEnd > 0)
|
|
139
|
+
stream.push(pending.slice(0, safeEnd));
|
|
140
|
+
pending = pending.slice(safeEnd);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (start > 0) {
|
|
144
|
+
stream.push(pending.slice(0, start));
|
|
145
|
+
pending = pending.slice(start);
|
|
146
|
+
}
|
|
147
|
+
const end = pending.indexOf('>');
|
|
148
|
+
if (end < 0)
|
|
149
|
+
return;
|
|
150
|
+
const close = closeHref.exec(pending.slice(end + 1));
|
|
151
|
+
if (close === null)
|
|
152
|
+
return;
|
|
153
|
+
const closeStart = end + 1 + close.index;
|
|
154
|
+
const closeEnd = closeStart + close[0].length;
|
|
155
|
+
stream.push(pending.slice(0, end + 1));
|
|
156
|
+
stream.push(rewriteHref(pending.slice(end + 1, closeStart), target, base, aliases));
|
|
157
|
+
stream.push(pending.slice(closeStart, closeEnd));
|
|
158
|
+
pending = pending.slice(closeEnd);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
return new Transform({
|
|
162
|
+
transform(chunk, _encoding, callback) {
|
|
163
|
+
pending += decoder.write(chunk);
|
|
164
|
+
flushPending(this, false);
|
|
165
|
+
callback();
|
|
166
|
+
},
|
|
167
|
+
flush(callback) {
|
|
168
|
+
pending += decoder.end();
|
|
169
|
+
flushPending(this, true);
|
|
170
|
+
callback();
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
/** The name half of a fresh account: short, hex, and easy to type again. */
|
|
175
|
+
export function makeRelayUser() {
|
|
176
|
+
return `dsh${randomBytes(RELAY_USER_BYTES).toString('hex')}`;
|
|
177
|
+
}
|
|
178
|
+
/** The password half of a fresh account. Minted once and then kept. */
|
|
179
|
+
export function makeRelayPassword() {
|
|
180
|
+
return randomBytes(RELAY_PASSWORD_BYTES).toString('hex');
|
|
181
|
+
}
|
|
182
|
+
/** The Basic credential a client signs in with, as it comes off the wire. */
|
|
183
|
+
function basicOf(header) {
|
|
184
|
+
if (typeof header !== 'string' || !header.startsWith('Basic '))
|
|
185
|
+
return undefined;
|
|
186
|
+
let decoded;
|
|
187
|
+
try {
|
|
188
|
+
decoded = Buffer.from(header.slice('Basic '.length), 'base64').toString('utf8');
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
const mark = decoded.indexOf(':');
|
|
194
|
+
if (mark < 0)
|
|
195
|
+
return undefined;
|
|
196
|
+
return { user: decoded.slice(0, mark), password: decoded.slice(mark + 1) };
|
|
197
|
+
}
|
|
198
|
+
/** The Basic credential to send on, for an account that has either half. */
|
|
199
|
+
function basicFor(config) {
|
|
200
|
+
return `Basic ${Buffer.from(`${config.user}:${config.password}`, 'utf8').toString('base64')}`;
|
|
201
|
+
}
|
|
202
|
+
/** The address a client is given for one configuration. */
|
|
203
|
+
function addressOf(config) {
|
|
204
|
+
if (!config.relayEnabled)
|
|
205
|
+
return '';
|
|
206
|
+
return relayUrl(config.relayPort, relayBasePath(config.url));
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* The forwarder itself.
|
|
210
|
+
*
|
|
211
|
+
* One instance lives for as long as the host runs. The host hands it the
|
|
212
|
+
* configuration it is holding — at startup, after every save, and on every card
|
|
213
|
+
* read — and it brings its listening socket in line with it. The configuration
|
|
214
|
+
* is kept rather than looked up per request, so a request never waits on a file
|
|
215
|
+
* read, and the account, the target and the headers it uses are all from one
|
|
216
|
+
* single version of the configuration.
|
|
217
|
+
*/
|
|
218
|
+
export class RelayServer {
|
|
219
|
+
config = defaultConfig();
|
|
220
|
+
server;
|
|
221
|
+
failure = '';
|
|
222
|
+
aliasFile = '';
|
|
223
|
+
aliases = new Map();
|
|
224
|
+
aliasWrites = Promise.resolve();
|
|
225
|
+
/** The port the socket in hand is bound to, so a change of port rebuilds it. */
|
|
226
|
+
bound = -1;
|
|
227
|
+
/** Socket changes cannot overlap a startup read, a save, or a status poll. */
|
|
228
|
+
pending = Promise.resolve();
|
|
229
|
+
enqueue(change) {
|
|
230
|
+
const next = this.pending.then(change);
|
|
231
|
+
this.pending = next.catch(() => undefined);
|
|
232
|
+
return next;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Bring the socket in line with a configuration.
|
|
236
|
+
*
|
|
237
|
+
* Off, or on with something missing, closes it rather than leaving a listener
|
|
238
|
+
* that answers every request with a complaint: a client that reaches a door
|
|
239
|
+
* gets a straight answer from the server behind it, or no door at all. A port
|
|
240
|
+
* that is merely taken is not a refusal — the configuration is good, the socket
|
|
241
|
+
* is not, and {@link view} says which.
|
|
242
|
+
*/
|
|
243
|
+
apply(config, stateDir) {
|
|
244
|
+
const snapshot = { ...config, dirs: [...config.dirs] };
|
|
245
|
+
return this.enqueue(() => this.applyNow(snapshot, stateDir));
|
|
246
|
+
}
|
|
247
|
+
async applyNow(config, stateDir) {
|
|
248
|
+
this.config = config;
|
|
249
|
+
this.failure = '';
|
|
250
|
+
if (!config.relayEnabled) {
|
|
251
|
+
await this.close();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
this.targetOf(config);
|
|
256
|
+
this.accountOf(config);
|
|
257
|
+
const headers = this.configuredHeaders(config);
|
|
258
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
259
|
+
validateHeaderName(name);
|
|
260
|
+
validateHeaderValue(name, value);
|
|
261
|
+
}
|
|
262
|
+
await this.loadAliases(stateDir);
|
|
263
|
+
}
|
|
264
|
+
catch (cause) {
|
|
265
|
+
this.failure = message(cause);
|
|
266
|
+
await this.close();
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (this.server !== undefined && this.bound === config.relayPort)
|
|
270
|
+
return;
|
|
271
|
+
await this.close();
|
|
272
|
+
await this.listen(config.relayPort);
|
|
273
|
+
}
|
|
274
|
+
async loadAliases(stateDir) {
|
|
275
|
+
if (stateDir === undefined)
|
|
276
|
+
return;
|
|
277
|
+
const file = join(stateDir, 'relay-paths.json');
|
|
278
|
+
if (file === this.aliasFile)
|
|
279
|
+
return;
|
|
280
|
+
this.aliases.clear();
|
|
281
|
+
let text;
|
|
282
|
+
try {
|
|
283
|
+
text = await readFile(file, 'utf8');
|
|
284
|
+
}
|
|
285
|
+
catch (cause) {
|
|
286
|
+
if (cause.code === 'ENOENT') {
|
|
287
|
+
this.aliasFile = file;
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
throw cause;
|
|
291
|
+
}
|
|
292
|
+
const saved = JSON.parse(text);
|
|
293
|
+
if (typeof saved !== 'object' || saved === null || !('version' in saved) || saved.version !== 1 || !('aliases' in saved)) {
|
|
294
|
+
throw new Error('转发文件名映射记录格式不正确');
|
|
295
|
+
}
|
|
296
|
+
const aliases = saved.aliases;
|
|
297
|
+
if (typeof aliases !== 'object' || aliases === null || Array.isArray(aliases)) {
|
|
298
|
+
throw new Error('转发文件名映射记录格式不正确');
|
|
299
|
+
}
|
|
300
|
+
for (const [key, value] of Object.entries(aliases)) {
|
|
301
|
+
if (typeof value === 'string' && key.startsWith('http'))
|
|
302
|
+
this.aliases.set(key, value);
|
|
303
|
+
}
|
|
304
|
+
this.aliasFile = file;
|
|
305
|
+
}
|
|
306
|
+
saveAliases() {
|
|
307
|
+
if (this.aliasFile === '')
|
|
308
|
+
return Promise.resolve();
|
|
309
|
+
const snapshot = JSON.stringify({ version: 1, aliases: Object.fromEntries(this.aliases) }, null, 2);
|
|
310
|
+
const file = this.aliasFile;
|
|
311
|
+
const next = this.aliasWrites.catch(() => undefined).then(() => writeAtomic(file, snapshot, 0o600));
|
|
312
|
+
this.aliasWrites = next;
|
|
313
|
+
return next;
|
|
314
|
+
}
|
|
315
|
+
changeAlias(key, value) {
|
|
316
|
+
if (this.aliases.get(key) === value)
|
|
317
|
+
return Promise.resolve();
|
|
318
|
+
if (value === undefined)
|
|
319
|
+
this.aliases.delete(key);
|
|
320
|
+
else
|
|
321
|
+
this.aliases.set(key, value);
|
|
322
|
+
return this.saveAliases();
|
|
323
|
+
}
|
|
324
|
+
/** What the card shows. Never throws: a failure is a field, not an exception. */
|
|
325
|
+
view() {
|
|
326
|
+
const config = this.config;
|
|
327
|
+
return {
|
|
328
|
+
enabled: config.relayEnabled,
|
|
329
|
+
port: config.relayPort,
|
|
330
|
+
url: addressOf(config),
|
|
331
|
+
user: config.relayUser,
|
|
332
|
+
password: config.relayPassword,
|
|
333
|
+
listening: this.server !== undefined,
|
|
334
|
+
error: this.failure,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
/** Stop listening. Called when the plugin is disposed. */
|
|
338
|
+
stop() {
|
|
339
|
+
return this.enqueue(async () => {
|
|
340
|
+
await this.close();
|
|
341
|
+
await this.aliasWrites.catch(() => undefined);
|
|
342
|
+
this.failure = '';
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
/** The target address, parsed, or the complaint the card shows. */
|
|
346
|
+
targetOf(config) {
|
|
347
|
+
const text = config.url.trim();
|
|
348
|
+
if (text === '')
|
|
349
|
+
throw new Error('转发要有 WebDAV 地址可去:先把上面的地址填上');
|
|
350
|
+
let url;
|
|
351
|
+
try {
|
|
352
|
+
url = new URL(text);
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
throw new Error(`WebDAV 地址不是地址:「${text}」`);
|
|
356
|
+
}
|
|
357
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
358
|
+
throw new Error(`WebDAV 地址要用 http 或 https:「${text}」`);
|
|
359
|
+
}
|
|
360
|
+
return url;
|
|
361
|
+
}
|
|
362
|
+
/** The account a client has to sign in with, or the complaint the card shows. */
|
|
363
|
+
accountOf(config) {
|
|
364
|
+
if (config.relayUser === '' || config.relayPassword === '') {
|
|
365
|
+
throw new Error('转发的账号还没生成:保存一次就会生成');
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/** Rebuild the socket for one port. Failures are recorded, never thrown. */
|
|
369
|
+
async listen(port) {
|
|
370
|
+
const server = createServer((req, res) => this.handle(req, res));
|
|
371
|
+
try {
|
|
372
|
+
await new Promise((settle, fail) => {
|
|
373
|
+
server.once('error', fail);
|
|
374
|
+
// Loopback only, and deliberately: this door is for programs on this
|
|
375
|
+
// computer, and the account is what stands behind it either way.
|
|
376
|
+
server.listen(port, '127.0.0.1', () => {
|
|
377
|
+
server.off('error', fail);
|
|
378
|
+
settle();
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
catch (cause) {
|
|
383
|
+
this.failure = `转发端口 ${port} 用不了:${message(cause)}`;
|
|
384
|
+
server.close();
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
// After it is up, an error must not take the host process down with it.
|
|
388
|
+
server.on('error', (cause) => {
|
|
389
|
+
this.failure = message(cause);
|
|
390
|
+
});
|
|
391
|
+
this.server = server;
|
|
392
|
+
this.bound = port;
|
|
393
|
+
}
|
|
394
|
+
/** Close the socket and anything still talking on it. */
|
|
395
|
+
async close() {
|
|
396
|
+
const server = this.server;
|
|
397
|
+
this.server = undefined;
|
|
398
|
+
this.bound = -1;
|
|
399
|
+
if (server === undefined)
|
|
400
|
+
return;
|
|
401
|
+
await new Promise((settle) => {
|
|
402
|
+
server.close(() => settle());
|
|
403
|
+
// A client holding a keep-alive connection would otherwise keep the port
|
|
404
|
+
// bound past the moment the card says it was let go.
|
|
405
|
+
server.closeAllConnections();
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* One forwarded request.
|
|
410
|
+
*
|
|
411
|
+
* The caller signs in with the relay's account, and only then is anything sent
|
|
412
|
+
* on: an address on loopback is not a boundary — any program on this computer
|
|
413
|
+
* can reach it — so the account is the whole of what decides who may spend the
|
|
414
|
+
* sync's credentials, and a caller that does not have it learns nothing.
|
|
415
|
+
*/
|
|
416
|
+
handle(req, res) {
|
|
417
|
+
const config = this.config;
|
|
418
|
+
if (!this.authorised(req, config)) {
|
|
419
|
+
this.refuse(res, 401, '这个转发地址要用你自己的账号密码', {
|
|
420
|
+
'www-authenticate': 'Basic realm="dsh-webdav-sync", charset="UTF-8"',
|
|
421
|
+
});
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
let target;
|
|
425
|
+
try {
|
|
426
|
+
target = this.targetOf(config);
|
|
427
|
+
}
|
|
428
|
+
catch (cause) {
|
|
429
|
+
this.refuse(res, 502, message(cause));
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const raw = req.url ?? '/';
|
|
433
|
+
const cut = raw.indexOf('?');
|
|
434
|
+
const pathname = cut < 0 ? raw : raw.slice(0, cut);
|
|
435
|
+
const search = cut < 0 ? '' : raw.slice(cut);
|
|
436
|
+
const upstreamPath = this.upstreamPath(target, pathname);
|
|
437
|
+
const basePath = target.pathname.replace(/\/+$/, '');
|
|
438
|
+
const pathOnServer = req.method === 'PROPFIND' && upstreamPath === basePath
|
|
439
|
+
? `${basePath}/`
|
|
440
|
+
: upstreamPath;
|
|
441
|
+
const depth = req.headers.depth;
|
|
442
|
+
const basename = pathOnServer.slice(pathOnServer.lastIndexOf('/') + 1);
|
|
443
|
+
const looksLikeFile = /\.[^./]+$/.test(basename) || [...this.aliases.values()].includes(pathOnServer);
|
|
444
|
+
// PROPFIND without Depth means infinity. Depth 0 is ambiguous, so preserve
|
|
445
|
+
// extensionless unknown paths rather than silently renaming a collection.
|
|
446
|
+
const collectionProbe = req.method === 'PROPFIND' && (depth === undefined || depth !== '0' || pathOnServer.endsWith('/') || !looksLikeFile);
|
|
447
|
+
const pathIsCollection = req.method === 'MKCOL' || req.method === 'OPTIONS';
|
|
448
|
+
const resourcePath = !collectionProbe && !pathIsCollection
|
|
449
|
+
? withBackupExtension(pathOnServer)
|
|
450
|
+
: pathOnServer;
|
|
451
|
+
const path = `${resourcePath}${search === '' ? target.search : search}`;
|
|
452
|
+
const forwardedHeaders = this.forwardHeaders(req, config);
|
|
453
|
+
const destination = this.rewriteDestination(req.headers.destination, config, target);
|
|
454
|
+
if (destination !== undefined)
|
|
455
|
+
forwardedHeaders.destination = destination.header;
|
|
456
|
+
if (req.method === 'PROPFIND')
|
|
457
|
+
forwardedHeaders['accept-encoding'] = 'identity';
|
|
458
|
+
const options = {
|
|
459
|
+
method: req.method ?? 'GET',
|
|
460
|
+
hostname: target.hostname,
|
|
461
|
+
port: target.port === '' ? (target.protocol === 'https:' ? 443 : 80) : Number(target.port),
|
|
462
|
+
path,
|
|
463
|
+
headers: forwardedHeaders,
|
|
464
|
+
};
|
|
465
|
+
const remoteKey = aliasKey(target.origin, resourcePath);
|
|
466
|
+
let answered = false;
|
|
467
|
+
const ask = target.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
468
|
+
const upstream = ask(options, (answer) => {
|
|
469
|
+
answered = true;
|
|
470
|
+
void (async () => {
|
|
471
|
+
const status = answer.statusCode ?? 502;
|
|
472
|
+
try {
|
|
473
|
+
if (status >= 200 && status < 300) {
|
|
474
|
+
if (req.method === 'MOVE') {
|
|
475
|
+
await this.changeAlias(remoteKey, undefined);
|
|
476
|
+
if (destination !== undefined) {
|
|
477
|
+
const destinationKey = aliasKey(target.origin, destination.remotePath);
|
|
478
|
+
await this.changeAlias(destinationKey, destination.remotePath === destination.clientPath ? undefined : destination.clientPath);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
else if (req.method === 'COPY') {
|
|
482
|
+
if (resourcePath !== pathOnServer)
|
|
483
|
+
await this.changeAlias(remoteKey, pathOnServer);
|
|
484
|
+
if (destination !== undefined) {
|
|
485
|
+
const destinationKey = aliasKey(target.origin, destination.remotePath);
|
|
486
|
+
await this.changeAlias(destinationKey, destination.remotePath === destination.clientPath ? undefined : destination.clientPath);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
else if ((req.method === 'DELETE' || (req.method === 'PUT' && resourcePath === pathOnServer)) && this.aliases.has(remoteKey)) {
|
|
490
|
+
await this.changeAlias(remoteKey, undefined);
|
|
491
|
+
}
|
|
492
|
+
else if (req.method !== 'DELETE' && resourcePath !== pathOnServer) {
|
|
493
|
+
await this.changeAlias(remoteKey, pathOnServer);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
const rewritePropfind = req.method === 'PROPFIND' && status === 207 && answer.headers['content-encoding'] === undefined;
|
|
497
|
+
res.writeHead(status, this.responseHeaders(answer, config, target, rewritePropfind));
|
|
498
|
+
if (rewritePropfind) {
|
|
499
|
+
answer.pipe(propfindHrefTransform(target, path, this.aliases)).pipe(res);
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
answer.pipe(res);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
catch (cause) {
|
|
506
|
+
answer.resume();
|
|
507
|
+
this.refuse(res, 502, `无法保存转发文件名映射:${message(cause)}`);
|
|
508
|
+
}
|
|
509
|
+
})();
|
|
510
|
+
});
|
|
511
|
+
upstream.setTimeout(FORWARD_TIMEOUT_MS, () => {
|
|
512
|
+
upstream.destroy(new Error(`转发到 ${config.url} 超时`));
|
|
513
|
+
});
|
|
514
|
+
upstream.on('error', (cause) => {
|
|
515
|
+
if (answered) {
|
|
516
|
+
// The answer had already started; all that is left is to cut it off.
|
|
517
|
+
res.destroy();
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
this.refuse(res, 502, `转发到服务器失败:${message(cause)}`);
|
|
521
|
+
});
|
|
522
|
+
req.pipe(upstream);
|
|
523
|
+
}
|
|
524
|
+
/** Whether a caller signed in with the relay's own account. */
|
|
525
|
+
authorised(req, config) {
|
|
526
|
+
if (config.relayUser === '' || config.relayPassword === '')
|
|
527
|
+
return false;
|
|
528
|
+
const given = basicOf(req.headers.authorization);
|
|
529
|
+
if (given === undefined)
|
|
530
|
+
return false;
|
|
531
|
+
// Both halves are compared even once one has failed, so the answer takes the
|
|
532
|
+
// same time whichever half a caller got wrong.
|
|
533
|
+
const user = sameSecret(given.user, config.relayUser);
|
|
534
|
+
const password = sameSecret(given.password, config.relayPassword);
|
|
535
|
+
return user && password;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Where a request the client made goes on the real server.
|
|
539
|
+
*
|
|
540
|
+
* The address the client was given carries the target's own base path, and
|
|
541
|
+
* clients differ in what they do with it: one joins its paths onto the host
|
|
542
|
+
* alone, another keeps the base path it was handed and appends to it. Both are
|
|
543
|
+
* made to work — a path that already begins with the base path is taken as it
|
|
544
|
+
* stands, and one that does not is given it — so the same address is correct
|
|
545
|
+
* for either habit rather than only for the one the author happened to test.
|
|
546
|
+
*/
|
|
547
|
+
upstreamPath(target, pathname) {
|
|
548
|
+
// A client that pasted the address with a trailing slash and joined its own
|
|
549
|
+
// path onto it arrives with two slashes at the seam. One is the artifact;
|
|
550
|
+
// everything else is passed on exactly as asked.
|
|
551
|
+
const asked = pathname.startsWith('//') ? pathname.slice(1) : pathname;
|
|
552
|
+
const base = target.pathname.replace(/\/+$/, '');
|
|
553
|
+
if (base === '')
|
|
554
|
+
return asked === '' ? '/' : asked;
|
|
555
|
+
if (asked === base || asked.startsWith(`${base}/`))
|
|
556
|
+
return asked;
|
|
557
|
+
const rest = asked === '' ? '/' : asked.startsWith('/') ? asked : `/${asked}`;
|
|
558
|
+
return `${base}${rest}`;
|
|
559
|
+
}
|
|
560
|
+
/** Map a same-relay Destination to the configured upstream and return its alias. */
|
|
561
|
+
rewriteDestination(header, config, target) {
|
|
562
|
+
if (typeof header !== 'string')
|
|
563
|
+
return undefined;
|
|
564
|
+
const local = new URL(relayUrl(config.relayPort, relayBasePath(config.url)));
|
|
565
|
+
let destination;
|
|
566
|
+
try {
|
|
567
|
+
destination = new URL(header, local);
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
return undefined;
|
|
571
|
+
}
|
|
572
|
+
if (destination.origin !== local.origin)
|
|
573
|
+
return undefined;
|
|
574
|
+
const clientPath = this.upstreamPath(target, destination.pathname);
|
|
575
|
+
const remotePath = destination.pathname.endsWith('/') ? clientPath : withBackupExtension(clientPath);
|
|
576
|
+
return {
|
|
577
|
+
header: `${target.origin}${remotePath}${destination.search}${destination.hash}`,
|
|
578
|
+
clientPath,
|
|
579
|
+
remotePath,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* The headers the forwarded request carries.
|
|
584
|
+
*
|
|
585
|
+
* The configured ones are the reason the forwarder exists, and the client's own
|
|
586
|
+
* are the protocol headers its request is made of, so a collision goes to the
|
|
587
|
+
* client. `authorization` is the one header that never comes from the client:
|
|
588
|
+
* it was addressed to this forwarder, not to the server, and the server is given
|
|
589
|
+
* the sync's own credential — or, when the configuration names an
|
|
590
|
+
* `Authorization` line of its own, exactly that line, since a hand-written one
|
|
591
|
+
* is the more specific instruction.
|
|
592
|
+
*/
|
|
593
|
+
/** Configuration headers obey the same connection boundary as client headers. */
|
|
594
|
+
configuredHeaders(config) {
|
|
595
|
+
const headers = parseHeaders(config.headers);
|
|
596
|
+
const skipped = connectionHeaders(headers);
|
|
597
|
+
return Object.fromEntries(Object.entries(headers).filter(([name]) => !skipped.has(name.toLowerCase()) && name.toLowerCase() !== 'host' && name.toLowerCase() !== 'expect'));
|
|
598
|
+
}
|
|
599
|
+
forwardHeaders(req, config) {
|
|
600
|
+
const taken = new Map();
|
|
601
|
+
for (const [name, value] of Object.entries(this.configuredHeaders(config))) {
|
|
602
|
+
taken.set(name.toLowerCase(), { name, value });
|
|
603
|
+
}
|
|
604
|
+
const skipped = connectionHeaders(req.headers);
|
|
605
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
606
|
+
if (value === undefined)
|
|
607
|
+
continue;
|
|
608
|
+
const lower = name.toLowerCase();
|
|
609
|
+
if (skipped.has(lower) || lower === 'host' || lower === 'authorization')
|
|
610
|
+
continue;
|
|
611
|
+
// A per-hop handshake: passed on, the server would hold the request waiting
|
|
612
|
+
// for a continue that is not coming from the same connection.
|
|
613
|
+
if (lower === 'expect')
|
|
614
|
+
continue;
|
|
615
|
+
taken.set(lower, { name, value });
|
|
616
|
+
}
|
|
617
|
+
if (!taken.has('authorization') && (config.user !== '' || config.password !== '')) {
|
|
618
|
+
taken.set('authorization', { name: 'authorization', value: basicFor(config) });
|
|
619
|
+
}
|
|
620
|
+
const out = {};
|
|
621
|
+
for (const { name, value } of taken.values())
|
|
622
|
+
out[name] = value;
|
|
623
|
+
return out;
|
|
624
|
+
}
|
|
625
|
+
/** The headers a response carries back: connection-level ones out, location mapped. */
|
|
626
|
+
responseHeaders(answer, config, target, rewriteBody = false) {
|
|
627
|
+
const out = {};
|
|
628
|
+
const skipped = connectionHeaders(answer.headers);
|
|
629
|
+
for (const [name, value] of Object.entries(answer.headers)) {
|
|
630
|
+
if (value === undefined)
|
|
631
|
+
continue;
|
|
632
|
+
const lower = name.toLowerCase();
|
|
633
|
+
if (skipped.has(lower))
|
|
634
|
+
continue;
|
|
635
|
+
if (rewriteBody && ['content-length', 'etag', 'content-md5', 'digest'].includes(lower))
|
|
636
|
+
continue;
|
|
637
|
+
if (lower === 'location') {
|
|
638
|
+
out[name] = Array.isArray(value)
|
|
639
|
+
? value.map((one) => this.rewriteLocation(one, config, target))
|
|
640
|
+
: this.rewriteLocation(value, config, target);
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
out[name] = value;
|
|
644
|
+
}
|
|
645
|
+
return out;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* A redirect that points at the real server is pointed back at this forwarder,
|
|
649
|
+
* path and query untouched. Left alone, the client would follow it straight to
|
|
650
|
+
* the server and from then on talk around the forwarder — losing the account
|
|
651
|
+
* and the headers this exists for.
|
|
652
|
+
*/
|
|
653
|
+
rewriteLocation(location, config, target) {
|
|
654
|
+
const basePath = target.pathname.replace(/\/+$/, '');
|
|
655
|
+
const here = relayUrl(config.relayPort, basePath);
|
|
656
|
+
const origin = `${target.protocol}//${target.host}`;
|
|
657
|
+
const prefix = `${origin}${basePath}`;
|
|
658
|
+
if (location.startsWith(prefix)) {
|
|
659
|
+
const rest = location.slice(prefix.length);
|
|
660
|
+
if (rest === '' || rest.startsWith('/') || rest.startsWith('?') || rest.startsWith('#')) {
|
|
661
|
+
return `${here}${rest}`;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
// A path-only location is relative to the target's own origin.
|
|
665
|
+
if (basePath !== '' && (location === basePath || location.startsWith(`${basePath}/`))) {
|
|
666
|
+
return `${here}${location.slice(basePath.length)}`;
|
|
667
|
+
}
|
|
668
|
+
return location;
|
|
669
|
+
}
|
|
670
|
+
/** Answer with a short plain-text refusal, unless the answer already started. */
|
|
671
|
+
refuse(res, status, text, extra = {}) {
|
|
672
|
+
if (res.headersSent) {
|
|
673
|
+
res.destroy();
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
res.writeHead(status, {
|
|
677
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
678
|
+
'cache-control': 'no-store',
|
|
679
|
+
...extra,
|
|
680
|
+
});
|
|
681
|
+
res.end(text);
|
|
682
|
+
}
|
|
683
|
+
}
|