pmtiles-swarm 0.50.1 → 0.53.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/CHANGELOG.md +59 -0
- package/docs/configuration.md +82 -15
- package/package.json +1 -1
- package/src/api.js +36 -12
- package/src/catalog.js +106 -0
- package/src/config.js +20 -0
- package/src/library.js +59 -5
- package/src/subscriptions.js +8 -0
- package/src/web/index.html +81 -5
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,65 @@
|
|
|
7
7
|
### 🐞 Bug fixes
|
|
8
8
|
- _...Add new stuff here..._
|
|
9
9
|
|
|
10
|
+
## 0.53.0
|
|
11
|
+
### ✨ Features and improvements
|
|
12
|
+
- **The three switches are now settable on every import, from the console.** **Serve file**, **Web
|
|
13
|
+
seed** and **Listed** columns on monitored folders, watched web locations, RSS feeds and remote
|
|
14
|
+
nodes, each offering `node` as well as yes and no — because unset is a real answer here, meaning
|
|
15
|
+
the archive follows the node rather than being switched off.
|
|
16
|
+
|
|
17
|
+
### 🐞 Bug fixes
|
|
18
|
+
- **An RSS feed could not ask for any of them.** 0.50.0 wired these through watched folders and
|
|
19
|
+
scheduled sources and stopped there, and an archive adopted from a feed takes a different path
|
|
20
|
+
through the library — which is the path a mirror node actually uses. A subscription now carries all
|
|
21
|
+
three, and there was no way to set them from the console on any import at all.
|
|
22
|
+
|
|
23
|
+
- **`selfWebSeed` on an import waits for the download to finish.** Publishing a web seed URL for an
|
|
24
|
+
archive that is still arriving would advertise an address answering `409`, and a peer handed a URL
|
|
25
|
+
that refuses spends its retries on it — worse than no web seed, and unfixable afterwards because by
|
|
26
|
+
then the URL is in every copy of the `.torrent`. The intention is recorded when the archive joins
|
|
27
|
+
and acted on at the first moment this node holds the whole file.
|
|
28
|
+
|
|
29
|
+
## 0.52.0
|
|
30
|
+
### ✨ Features and improvements
|
|
31
|
+
- **`publishingUrl`, for the URLs that have to be permanent.** Almost every URL this node emits is
|
|
32
|
+
worked out per request, deliberately: a node answering on several domains should name itself as
|
|
33
|
+
whichever one was asked for, and leaving `publicUrl` unset is what allows that. A web seed is not
|
|
34
|
+
read once, though — it is written into the `.torrent` and the magnet, served byte for byte to
|
|
35
|
+
everyone who fetches either, and never rewritten — so it has to be one address rather than
|
|
36
|
+
whichever the last request happened to arrive on.
|
|
37
|
+
|
|
38
|
+
`publishingUrl` is that address, and nothing else. TileJSON, tile templates, `.torrent` links,
|
|
39
|
+
style URLs and the feeds go on naming whichever host the request arrived as, so the multi-domain
|
|
40
|
+
behaviour is intact. It is taken exactly as written, port and path included: a node listening on
|
|
41
|
+
8090 behind a load balancer answering on 443 sets `https://swarm.example.org` and gets it, with
|
|
42
|
+
none of the port substitution that applies to a request reaching the node directly.
|
|
43
|
+
|
|
44
|
+
- **A "Published as" field beside the web seed switch**, prefilled with whatever the node would use
|
|
45
|
+
and editable until the moment the switch is turned on. Which is the point: it is the one URL here
|
|
46
|
+
that cannot be taken back, so it should be readable — and correctable — before it is.
|
|
47
|
+
|
|
48
|
+
Resolution order is deliberate: the field, then `publishingUrl`, then `publicUrl`, then the
|
|
49
|
+
request. With none of them the setting is refused rather than invented.
|
|
50
|
+
|
|
51
|
+
## 0.51.0
|
|
52
|
+
### ✨ Features and improvements
|
|
53
|
+
- **A web seed URL that no peer could fetch is caught before it is published.** Nothing rewrites a
|
|
54
|
+
web seed afterwards — a `.torrent` is served byte for byte as it was written, so the URL that goes
|
|
55
|
+
in is the URL every peer receives for as long as the torrent exists. A loopback address is now
|
|
56
|
+
refused outright, since `127.0.0.1` names the machine asking and every peer given it would try to
|
|
57
|
+
fetch the archive from itself and retry for ever. A private address, or a hostname with no domain
|
|
58
|
+
in it, is published with a warning rather than blocked: a node syncing to its own peers across a
|
|
59
|
+
LAN is a real arrangement, and the internal address is the right answer there.
|
|
60
|
+
|
|
61
|
+
### 🐞 Bug fixes
|
|
62
|
+
- **The HTTP sources tab showed the admin port beside the serve switch.** The console built the URL
|
|
63
|
+
from the address in the browser's bar, and the console is served from the admin listener — so the
|
|
64
|
+
one port that is not for the public was the one displayed next to a switch that publishes a URL to
|
|
65
|
+
the whole swarm. It now asks the node, which already answers this correctly everywhere else. What
|
|
66
|
+
was actually written into the `.torrent` and the magnet was right throughout; only the label was
|
|
67
|
+
wrong.
|
|
68
|
+
|
|
10
69
|
## 0.50.1
|
|
11
70
|
### ✨ Features and improvements
|
|
12
71
|
|
package/docs/configuration.md
CHANGED
|
@@ -315,11 +315,12 @@ rotation stays out across the restart you were probably about to do.
|
|
|
315
315
|
|
|
316
316
|
## Offering the archive file itself
|
|
317
317
|
|
|
318
|
-
| setting | default |
|
|
319
|
-
| ---------------- | ------- |
|
|
320
|
-
| `serveArchive` | `false` | answer `/archives/<infohash>/archive.pmtiles`
|
|
321
|
-
| `
|
|
322
|
-
| `
|
|
318
|
+
| setting | default | |
|
|
319
|
+
| ---------------- | ------- | ------------------------------------------------------- |
|
|
320
|
+
| `serveArchive` | `false` | answer `/archives/<infohash>/archive.pmtiles` |
|
|
321
|
+
| `publishingUrl` | unset | the address to use where a URL must outlive the request |
|
|
322
|
+
| `selfWebSeed` | `false` | publish this node as a web seed for archives it holds |
|
|
323
|
+
| `publicDownload` | `false` | offer them as downloads on the public catalogue page |
|
|
323
324
|
|
|
324
325
|
Three switches rather than one, because they are three different exposures and a
|
|
325
326
|
node can reasonably want any of them without the others.
|
|
@@ -345,6 +346,36 @@ GET /latest/<category>/archive.pmtiles whichever is current, with an ETag
|
|
|
345
346
|
|
|
346
347
|
With it off, both answer `403`.
|
|
347
348
|
|
|
349
|
+
### `publishingUrl`
|
|
350
|
+
|
|
351
|
+
The address to use for URLs that outlive the request that made them.
|
|
352
|
+
|
|
353
|
+
Almost every URL this node emits is worked out per request, deliberately: a node
|
|
354
|
+
answering on several domains should name itself as whichever one was asked for,
|
|
355
|
+
and leaving [`publicUrl`](#publicurl) unset is what allows that. Read once, by
|
|
356
|
+
whoever asked, a per-request answer is the correct answer.
|
|
357
|
+
|
|
358
|
+
A web seed is not read once. It is written into the `.torrent` and the magnet,
|
|
359
|
+
served byte for byte to everyone who fetches either, and never rewritten — so it
|
|
360
|
+
has to be one address rather than whichever the last request happened to arrive
|
|
361
|
+
on. This is that address.
|
|
362
|
+
|
|
363
|
+
Narrower than `publicUrl` on purpose: `publicUrl` overrides every URL the node
|
|
364
|
+
emits and so gives up the multi-domain behaviour, while this overrides only the
|
|
365
|
+
ones that have to be permanent. Everything else — TileJSON, tile templates,
|
|
366
|
+
`.torrent` links, style URLs, the feeds — goes on naming whichever host the
|
|
367
|
+
request arrived as.
|
|
368
|
+
|
|
369
|
+
```json
|
|
370
|
+
{
|
|
371
|
+
"publishingUrl": "https://swarm.example.org"
|
|
372
|
+
}
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
Unset falls back to `publicUrl` and then to the request. Whatever it resolves to
|
|
376
|
+
is what the console shows in the **Published as** field, where it can be
|
|
377
|
+
corrected before it becomes permanent.
|
|
378
|
+
|
|
348
379
|
### `selfWebSeed`
|
|
349
380
|
|
|
350
381
|
Writes this node's own `archive.pmtiles` URL into the torrent's `url-list`, so
|
|
@@ -358,10 +389,35 @@ node. Turning it off takes the URL back out of the `.torrent` and the magnet —
|
|
|
358
389
|
but peers already holding either keep trying it until they refresh, so this
|
|
359
390
|
withdraws an advertisement rather than closing a door.
|
|
360
391
|
|
|
361
|
-
The node has to know what it is called
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
392
|
+
The node has to know what it is called, and this is the one URL where "whichever
|
|
393
|
+
name was asked for" is not good enough. In order of how deliberate it is:
|
|
394
|
+
|
|
395
|
+
1. **The field beside the switch in the console.** Prefilled with whatever the
|
|
396
|
+
node would use, editable until the moment the switch is turned on, and after
|
|
397
|
+
that it is what peers hold.
|
|
398
|
+
2. **[`publishingUrl`](#publishingurl)** — the node's answer for exactly this
|
|
399
|
+
question, and the one to set on a node that answers on several domains.
|
|
400
|
+
3. **[`publicUrl`](#publicurl)**, for a node that has overridden everything
|
|
401
|
+
anyway.
|
|
402
|
+
4. **The request**, which is a guess, but the same guess every other URL makes.
|
|
403
|
+
|
|
404
|
+
With none of them the setting is refused rather than invented: a guessed web
|
|
405
|
+
seed URL is published and then followed.
|
|
406
|
+
|
|
407
|
+
**Nothing rewrites a web seed after it is published.** A `.torrent` is served
|
|
408
|
+
byte for byte as it was written, so the URL that went in is the URL every peer
|
|
409
|
+
receives, for as long as the torrent exists. Two consequences:
|
|
410
|
+
|
|
411
|
+
- A loopback address is refused. `127.0.0.1` names the machine asking, so every
|
|
412
|
+
peer given it would try to fetch the archive from itself and retry for ever.
|
|
413
|
+
- A private address, or a hostname with no domain in it, is published with a
|
|
414
|
+
warning rather than blocked. A node syncing to its own peers across a LAN is a
|
|
415
|
+
real arrangement and the internal address is the right answer there — but it is
|
|
416
|
+
worth knowing that peers outside that network cannot use it.
|
|
417
|
+
|
|
418
|
+
The console asks the node for the URL rather than building one from the address
|
|
419
|
+
in the browser's bar, since the console is served from the admin listener and
|
|
420
|
+
that is the one port that is not for the public.
|
|
365
421
|
|
|
366
422
|
### `publicDownload`
|
|
367
423
|
|
|
@@ -377,12 +433,23 @@ web seed, because a client spends its retries on it, and a download link that
|
|
|
377
433
|
|
|
378
434
|
### Per archive, per folder, per source
|
|
379
435
|
|
|
380
|
-
The
|
|
381
|
-
[
|
|
382
|
-
may carry
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
436
|
+
The node's setting is the default. A [watched folder](#watched-folders), a
|
|
437
|
+
[scheduled source](#scheduled-sources), an [RSS feed](#subscriptions) and a
|
|
438
|
+
remote node may each carry their own — the **Serve file**, **Web seed** and
|
|
439
|
+
**Listed** columns on those tables, where `node` means "no opinion" rather than
|
|
440
|
+
"off". And any individual archive can be switched in the console, under **HTTP
|
|
441
|
+
sources** in its details.
|
|
442
|
+
|
|
443
|
+
An archive that says nothing goes on following the node, so changing the node's
|
|
444
|
+
answer reaches every archive that never had one of its own.
|
|
445
|
+
|
|
446
|
+
Unlike `md5`, these do apply to a subscription. `md5` is a hashing pass that only
|
|
447
|
+
happens where a torrent is built, and a subscription adopts one somebody else
|
|
448
|
+
built; this is about what happens to the archive afterwards, which is this node's
|
|
449
|
+
business whoever made it. **`selfWebSeed` waits for the download to finish**: a
|
|
450
|
+
web seed URL for an archive still arriving answers `409`, and a peer handed a URL
|
|
451
|
+
that refuses spends its retries on it — worse than no web seed, and unfixable
|
|
452
|
+
afterwards, because by then the URL is in every copy of the `.torrent`.
|
|
386
453
|
|
|
387
454
|
## Trackers
|
|
388
455
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmtiles-swarm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.0",
|
|
4
4
|
"description": "BitTorrent distribution for PMTiles map archives: create torrents, watch folders, publish and subscribe to RSS feeds, and seed through qBittorrent or an embedded client",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/api.js
CHANGED
|
@@ -13,7 +13,11 @@ import {
|
|
|
13
13
|
hashToken,
|
|
14
14
|
isPublicSurface,
|
|
15
15
|
} from './auth.js';
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
normalizeCategories,
|
|
18
|
+
publishingBase,
|
|
19
|
+
publishingFor,
|
|
20
|
+
} from './catalog.js';
|
|
17
21
|
import { mutableMagnet, trackersFromMagnet } from './mutable.js';
|
|
18
22
|
import { guessKind } from './library.js';
|
|
19
23
|
import { QBittorrentEngine } from './engines/qbittorrent.js';
|
|
@@ -1014,7 +1018,26 @@ export function createApp({
|
|
|
1014
1018
|
reading,
|
|
1015
1019
|
diskBytes,
|
|
1016
1020
|
served,
|
|
1017
|
-
publishing:
|
|
1021
|
+
publishing: {
|
|
1022
|
+
...publishingFor(entry, config),
|
|
1023
|
+
// What this node would actually publish, worked out here rather than
|
|
1024
|
+
// in the browser. The console is served from the admin listener, so
|
|
1025
|
+
// `location.origin` there names the one port that is not for the
|
|
1026
|
+
// public — and a web seed built from it is handed to every peer in
|
|
1027
|
+
// the swarm.
|
|
1028
|
+
// Whatever was actually published for this archive, where one was:
|
|
1029
|
+
// the record is the truth about what peers hold, and it need not be
|
|
1030
|
+
// what this node would build today.
|
|
1031
|
+
url:
|
|
1032
|
+
entry.selfWebSeedUrl ??
|
|
1033
|
+
`${publishingBase({
|
|
1034
|
+
config,
|
|
1035
|
+
requestBase: baseUrl(req),
|
|
1036
|
+
})}/archives/${entry.infoHash}/archive.pmtiles`,
|
|
1037
|
+
// The base the switch would use, so the console can offer it for
|
|
1038
|
+
// editing before anything permanent is written.
|
|
1039
|
+
base: publishingBase({ config, requestBase: baseUrl(req) }),
|
|
1040
|
+
},
|
|
1018
1041
|
});
|
|
1019
1042
|
}),
|
|
1020
1043
|
);
|
|
@@ -1137,16 +1160,17 @@ export function createApp({
|
|
|
1137
1160
|
route(async (req, res) => {
|
|
1138
1161
|
const body = req.body ?? {};
|
|
1139
1162
|
try {
|
|
1140
|
-
const result = await library.setPublishing(
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
//
|
|
1144
|
-
|
|
1145
|
-
//
|
|
1146
|
-
//
|
|
1147
|
-
//
|
|
1148
|
-
|
|
1149
|
-
|
|
1163
|
+
const result = await library.setPublishing(req.params.infoHash, body, {
|
|
1164
|
+
// Typed into the field beside the switch, where it beats
|
|
1165
|
+
// everything else: this is the one URL a person gets to decide
|
|
1166
|
+
// deliberately, because it is the one that cannot be taken back.
|
|
1167
|
+
publishingUrl: body.publishingUrl,
|
|
1168
|
+
// Otherwise the request it is being asked on is the best evidence
|
|
1169
|
+
// available, the same reasoning the TileJSON already uses — and
|
|
1170
|
+
// with the public port rather than the admin one, since a seed URL
|
|
1171
|
+
// on a listener bound to localhost reaches no peer at all.
|
|
1172
|
+
baseUrl: baseUrl(req),
|
|
1173
|
+
});
|
|
1150
1174
|
res.json(result);
|
|
1151
1175
|
} catch (error) {
|
|
1152
1176
|
const status = /unknown archive/.test(error.message) ? 404 : 400;
|
package/src/catalog.js
CHANGED
|
@@ -260,3 +260,109 @@ export class Catalog {
|
|
|
260
260
|
return this.#writing;
|
|
261
261
|
}
|
|
262
262
|
}
|
|
263
|
+
/**
|
|
264
|
+
* Whether a URL is one other people could plausibly fetch.
|
|
265
|
+
*
|
|
266
|
+
* A web seed is not a setting; it is written into the `.torrent` and the
|
|
267
|
+
* magnet, and served to everyone who asks for either. Nothing rewrites it
|
|
268
|
+
* afterwards — the file is handed out byte for byte — so a URL that names
|
|
269
|
+
* this machine's own loopback interface is not a mistake that gets corrected
|
|
270
|
+
* later. It is distributed, followed, and retried by every peer in the swarm
|
|
271
|
+
* for as long as the torrent exists.
|
|
272
|
+
*
|
|
273
|
+
* Loopback is the only case refused outright, because it cannot be right for
|
|
274
|
+
* anybody: `127.0.0.1` means the peer's own machine, not this one. A private
|
|
275
|
+
* address is a different matter — a node syncing to its own peers across a LAN
|
|
276
|
+
* is a real arrangement, and this is not the place to overrule it — so that is
|
|
277
|
+
* reported rather than blocked.
|
|
278
|
+
* @param {string} url - The candidate web seed.
|
|
279
|
+
* @returns {{ok: boolean, why?: string, warning?: string}} - Whether to
|
|
280
|
+
* publish it, and what to say about it either way.
|
|
281
|
+
*/
|
|
282
|
+
export function reachability(url) {
|
|
283
|
+
let host;
|
|
284
|
+
try {
|
|
285
|
+
host = new URL(url).hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
286
|
+
} catch {
|
|
287
|
+
return { ok: false, why: `not a URL: ${url}` };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (host === 'localhost' || host === '::1' || /^127\./.test(host)) {
|
|
291
|
+
return {
|
|
292
|
+
ok: false,
|
|
293
|
+
why:
|
|
294
|
+
`${host} names the machine asking, not this one, so a web seed at ` +
|
|
295
|
+
'this address is unusable by every peer that receives it. Set ' +
|
|
296
|
+
'publicUrl to the address this node is reachable at.',
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const private4 =
|
|
301
|
+
/^10\./.test(host) ||
|
|
302
|
+
/^192\.168\./.test(host) ||
|
|
303
|
+
/^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
|
|
304
|
+
/^169\.254\./.test(host);
|
|
305
|
+
// fc00::/7, the IPv6 unique-local range.
|
|
306
|
+
const private6 = /^f[cd][0-9a-f]{2}:/.test(host);
|
|
307
|
+
if (private4 || private6 || host.endsWith('.local')) {
|
|
308
|
+
return {
|
|
309
|
+
ok: true,
|
|
310
|
+
warning:
|
|
311
|
+
`${host} is a private address. It will be published in the .torrent ` +
|
|
312
|
+
'and the magnet as it stands, and nothing rewrites it later — peers ' +
|
|
313
|
+
'outside this network cannot use it.',
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// A bare name with no dot in it resolves only where the same search domain
|
|
318
|
+
// does, which is a narrower place than the swarm.
|
|
319
|
+
if (!host.includes('.')) {
|
|
320
|
+
return {
|
|
321
|
+
ok: true,
|
|
322
|
+
warning:
|
|
323
|
+
`${host} has no domain, so it resolves only on networks that already ` +
|
|
324
|
+
'know the name. Peers elsewhere cannot use it.',
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return { ok: true };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The base a URL gets when it is going to outlive the request that made it.
|
|
333
|
+
*
|
|
334
|
+
* Almost every URL this node emits is worked out per request, on purpose: a
|
|
335
|
+
* node answering on several domains should name itself as whichever one was
|
|
336
|
+
* asked, and `publicUrl` is deliberately left unset to allow that. That is the
|
|
337
|
+
* right answer for a TileJSON, a `.torrent` link or a style URL — read once,
|
|
338
|
+
* by whoever asked, and correct for them.
|
|
339
|
+
*
|
|
340
|
+
* A web seed is not that. It is written into the `.torrent` and the magnet and
|
|
341
|
+
* served byte for byte to everyone who asks for either, so it has to be one
|
|
342
|
+
* address rather than whichever the last request happened to arrive on. This
|
|
343
|
+
* is where that address comes from, in order of how deliberate it is:
|
|
344
|
+
*
|
|
345
|
+
* 1. Given outright — the field beside the switch in the console.
|
|
346
|
+
* 2. `publishingUrl`, the node's answer for exactly this question.
|
|
347
|
+
* 3. `publicUrl`, if a node has overridden everything anyway.
|
|
348
|
+
* 4. The request, which is a guess, but the same guess every other URL makes.
|
|
349
|
+
*
|
|
350
|
+
* @param {object} [options] - `explicit`, `config` and `requestBase`.
|
|
351
|
+
* @returns {string} - A base with no trailing slash, or an empty string.
|
|
352
|
+
*/
|
|
353
|
+
export function publishingBase({ explicit, config, requestBase } = {}) {
|
|
354
|
+
const candidates = [
|
|
355
|
+
explicit,
|
|
356
|
+
config?.publishingUrl,
|
|
357
|
+
config?.publicUrl,
|
|
358
|
+
requestBase,
|
|
359
|
+
];
|
|
360
|
+
for (const candidate of candidates) {
|
|
361
|
+
// An empty or whitespace value means "not set", not "use an empty base" —
|
|
362
|
+
// the same reading `publicUrl` gets, and for the same reason: clearing a
|
|
363
|
+
// key by emptying it is what an operator naturally does to a JSON file.
|
|
364
|
+
const value = String(candidate ?? '').trim();
|
|
365
|
+
if (value) return value.replace(/\/+$/, '');
|
|
366
|
+
}
|
|
367
|
+
return '';
|
|
368
|
+
}
|
package/src/config.js
CHANGED
|
@@ -234,6 +234,26 @@ const DEFAULTS = {
|
|
|
234
234
|
allowUnauthenticated: false,
|
|
235
235
|
/** Public base URL, used to build absolute links in the RSS feed and TileJSON. */
|
|
236
236
|
publicUrl: undefined,
|
|
237
|
+
/**
|
|
238
|
+
* The one address to use for URLs that outlive the request that made them.
|
|
239
|
+
*
|
|
240
|
+
* Almost every URL this node emits is worked out per request, deliberately: a
|
|
241
|
+
* node answering on several domains should name itself as whichever one was
|
|
242
|
+
* asked for, and leaving `publicUrl` unset is what allows that. Read once, by
|
|
243
|
+
* whoever asked, a per-request answer is the correct answer.
|
|
244
|
+
*
|
|
245
|
+
* A web seed is not read once. It is written into the `.torrent` and the
|
|
246
|
+
* magnet, served byte for byte to everyone who fetches either, and never
|
|
247
|
+
* rewritten — so it has to be one address rather than whichever the last
|
|
248
|
+
* request happened to arrive on. Set this to that address.
|
|
249
|
+
*
|
|
250
|
+
* Narrower than `publicUrl` on purpose. `publicUrl` overrides every URL the
|
|
251
|
+
* node emits and so gives up the multi-domain behaviour; this overrides only
|
|
252
|
+
* the ones that have to be permanent. Unset falls back to `publicUrl`, then
|
|
253
|
+
* to the request — and the console lets the address be typed at the moment
|
|
254
|
+
* the switch is turned on, which beats both.
|
|
255
|
+
*/
|
|
256
|
+
publishingUrl: undefined,
|
|
237
257
|
/**
|
|
238
258
|
* Trust X-Forwarded-* headers, for running behind a reverse proxy or CDN.
|
|
239
259
|
* Takes anything Express accepts: `true`, a hop count, or a subnet list.
|
package/src/library.js
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
promote,
|
|
12
12
|
suffixFor,
|
|
13
13
|
} from './incomplete.js';
|
|
14
|
-
import { publishingFor } from './catalog.js';
|
|
14
|
+
import { publishingBase, publishingFor, reachability } from './catalog.js';
|
|
15
15
|
import { checkOrigin, fingerprintOrigin } from './origin.js';
|
|
16
16
|
import { probePMTiles } from './pmtiles-probe.js';
|
|
17
17
|
import {
|
|
@@ -526,6 +526,39 @@ export class Library {
|
|
|
526
526
|
* @returns {Promise<object>} - The updated catalog entry.
|
|
527
527
|
*/
|
|
528
528
|
async finalize(infoHash) {
|
|
529
|
+
const settled = await this.#finalizeOnce(infoHash);
|
|
530
|
+
|
|
531
|
+
// Only now, and never at import. A web seed URL for an archive that is
|
|
532
|
+
// still arriving answers 409, and a peer handed a URL that refuses spends
|
|
533
|
+
// its retries on it — worse than no web seed at all, and unfixable
|
|
534
|
+
// afterwards, because the URL is in the .torrent every peer holds. So a
|
|
535
|
+
// subscription records the intention when it joins and it is acted on
|
|
536
|
+
// here, at the first moment this node actually holds the whole file.
|
|
537
|
+
//
|
|
538
|
+
// Safe to reach on an archive that was already complete: setPublishing
|
|
539
|
+
// works from what is on record rather than from a transition, so this does
|
|
540
|
+
// nothing the second time — and does the right thing the first time for an
|
|
541
|
+
// archive that finished before the setting existed.
|
|
542
|
+
if (settled && publishingFor(settled, this.#config).selfWebSeed) {
|
|
543
|
+
try {
|
|
544
|
+
await this.setPublishing(infoHash, {});
|
|
545
|
+
return this.#catalog.get(infoHash) ?? settled;
|
|
546
|
+
} catch (error) {
|
|
547
|
+
console.warn(
|
|
548
|
+
`[web seed] ${settled.name} is not published as a web seed by this ` +
|
|
549
|
+
`node: ${error.message}`,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return settled;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* The rename and the bookkeeping, without the publishing that follows it.
|
|
558
|
+
* @param {string} infoHash - The archive that finished.
|
|
559
|
+
* @returns {Promise<object>} - The updated catalog entry.
|
|
560
|
+
*/
|
|
561
|
+
async #finalizeOnce(infoHash) {
|
|
529
562
|
const entry = this.#catalog.get(infoHash);
|
|
530
563
|
if (!entry) throw new Error('unknown archive');
|
|
531
564
|
if (entry.complete) return entry;
|
|
@@ -1145,6 +1178,11 @@ export class Library {
|
|
|
1145
1178
|
complete,
|
|
1146
1179
|
// Held until the download finishes, which may be hours away.
|
|
1147
1180
|
originMtime: options.originMtime,
|
|
1181
|
+
// Recorded now, applied when the download finishes. Unset leaves the
|
|
1182
|
+
// archive following the node, which is the rule everywhere else.
|
|
1183
|
+
serveArchive: options.serveArchive,
|
|
1184
|
+
selfWebSeed: options.selfWebSeed,
|
|
1185
|
+
publicDownload: options.publicDownload,
|
|
1148
1186
|
// What the peer that offered this says it holds, where it said anything.
|
|
1149
1187
|
// The head warmer replaces it with what the archive's own header says as
|
|
1150
1188
|
// soon as it can read one; until then this is what makes the archive
|
|
@@ -2869,18 +2907,31 @@ export class Library {
|
|
|
2869
2907
|
// so nothing would ever have published it.
|
|
2870
2908
|
const published = entry.selfWebSeedUrl ?? null;
|
|
2871
2909
|
let webSeed = published;
|
|
2910
|
+
let warning = null;
|
|
2872
2911
|
|
|
2873
2912
|
if (after.selfWebSeed && !published) {
|
|
2874
|
-
const base =
|
|
2875
|
-
|
|
2876
|
-
.
|
|
2913
|
+
const base = publishingBase({
|
|
2914
|
+
// Given outright beats the node's own answer, which beats the request
|
|
2915
|
+
// this happens to have arrived on. See publishingBase().
|
|
2916
|
+
explicit: options.publishingUrl,
|
|
2917
|
+
config: this.#config,
|
|
2918
|
+
requestBase: options.baseUrl,
|
|
2919
|
+
});
|
|
2877
2920
|
if (!base) {
|
|
2878
2921
|
throw new Error(
|
|
2879
2922
|
'cannot publish this node as a web seed without knowing its own ' +
|
|
2880
|
-
'URL: set
|
|
2923
|
+
'URL: set publishingUrl, or give one with the request',
|
|
2881
2924
|
);
|
|
2882
2925
|
}
|
|
2883
2926
|
webSeed = `${base}/archives/${infoHash}/archive.pmtiles`;
|
|
2927
|
+
// Checked before it goes anywhere. Nothing rewrites a web seed once it
|
|
2928
|
+
// is in a .torrent -- the file is served byte for byte to everyone who
|
|
2929
|
+
// asks -- so a URL that cannot work is not a mistake that gets corrected
|
|
2930
|
+
// on the next request. It is distributed and then retried for ever.
|
|
2931
|
+
const reach = reachability(webSeed);
|
|
2932
|
+
if (!reach.ok) throw new Error(reach.why);
|
|
2933
|
+
if (reach.warning) console.warn(`[web seed] ${reach.warning}`);
|
|
2934
|
+
warning = reach.warning ?? null;
|
|
2884
2935
|
await this.addWebSeeds(infoHash, [webSeed]);
|
|
2885
2936
|
// Only the field. put() merges, so spreading the entry captured before
|
|
2886
2937
|
// addWebSeeds would write the old webSeeds and the old magnet back over
|
|
@@ -2900,6 +2951,9 @@ export class Library {
|
|
|
2900
2951
|
return {
|
|
2901
2952
|
...after,
|
|
2902
2953
|
webSeed,
|
|
2954
|
+
// Published anyway, and said out loud. A node syncing to its own peers
|
|
2955
|
+
// across a LAN is a real arrangement and not this code's to overrule.
|
|
2956
|
+
warning,
|
|
2903
2957
|
// Named so a caller can warn about the one change that is not merely a
|
|
2904
2958
|
// setting moving: this URL is already in the hands of every peer that
|
|
2905
2959
|
// holds the torrent, and they will go on trying it for a while.
|
package/src/subscriptions.js
CHANGED
|
@@ -423,6 +423,14 @@ export class SubscriptionManager {
|
|
|
423
423
|
// 698 GiB download before starting one, which means it is populated
|
|
424
424
|
// before a single byte exists here.
|
|
425
425
|
summarySource: 'feed',
|
|
426
|
+
// What this node will offer of the archive once it holds it. Unset here
|
|
427
|
+
// means the node's own answer applies, the same rule as everywhere else.
|
|
428
|
+
// Nothing takes effect until the download finishes: a web seed URL for
|
|
429
|
+
// an archive that is still arriving answers 409, which is worse than no
|
|
430
|
+
// web seed because peers spend their retries on it.
|
|
431
|
+
serveArchive: subscription.serveArchive,
|
|
432
|
+
selfWebSeed: subscription.selfWebSeed,
|
|
433
|
+
publicDownload: subscription.publicDownload,
|
|
426
434
|
};
|
|
427
435
|
|
|
428
436
|
// The .torrent is preferred where there is one: it carries the trackers
|
package/src/web/index.html
CHANGED
|
@@ -392,6 +392,13 @@
|
|
|
392
392
|
would rearrange the panel under a click, and would leave no sign of
|
|
393
393
|
why the option had gone. */
|
|
394
394
|
.publish input:disabled + span { opacity: 0.55; }
|
|
395
|
+
/* The address a web seed would be published at. Beside the switch it
|
|
396
|
+
belongs to rather than in settings, because it is decided once, at the
|
|
397
|
+
moment it stops being editable. */
|
|
398
|
+
.pubbase { margin: -0.2rem 0 0 1.6rem; }
|
|
399
|
+
.pubbase.hidden { display: none; }
|
|
400
|
+
.pubbase label { display: block; font-size: 0.8rem; color: var(--muted); }
|
|
401
|
+
.pubbase input { width: 100%; max-width: 32rem; margin: 0.2rem 0; }
|
|
395
402
|
</style>
|
|
396
403
|
</head>
|
|
397
404
|
<body>
|
|
@@ -1821,11 +1828,14 @@
|
|
|
1821
1828
|
// says nothing defers to the node's own answer, and what belongs
|
|
1822
1829
|
// in a checkbox is what is actually happening.
|
|
1823
1830
|
const on = entry.publishing ?? {};
|
|
1824
|
-
//
|
|
1825
|
-
//
|
|
1826
|
-
//
|
|
1827
|
-
//
|
|
1831
|
+
// The node's own answer, not this page's. The console is served
|
|
1832
|
+
// from the admin listener, so `location.origin` here is the one
|
|
1833
|
+
// port that is not for the public — showing it beside a switch
|
|
1834
|
+
// that publishes a URL to the whole swarm is how a useless web
|
|
1835
|
+
// seed gets advertised. The fallback is for an older node that
|
|
1836
|
+
// does not send one.
|
|
1828
1837
|
const archiveUrl =
|
|
1838
|
+
on.url ??
|
|
1829
1839
|
`${location.origin}/archives/${entry.infoHash}/archive.pmtiles`;
|
|
1830
1840
|
|
|
1831
1841
|
// Three switches rather than one, because they are three separate
|
|
@@ -1867,6 +1877,25 @@
|
|
|
1867
1877
|
while.`,
|
|
1868
1878
|
!on.serveArchive,
|
|
1869
1879
|
)}
|
|
1880
|
+
<div class="pubbase${on.serveArchive ? '' : ' hidden'}">
|
|
1881
|
+
<label for="publish-base">Published as</label>
|
|
1882
|
+
<input
|
|
1883
|
+
id="publish-base"
|
|
1884
|
+
value="${escapeHtml(on.base ?? '')}"
|
|
1885
|
+
placeholder="https://swarm.example.org"
|
|
1886
|
+
spellcheck="false"
|
|
1887
|
+
${on.selfWebSeed ? 'disabled' : ''}
|
|
1888
|
+
/>
|
|
1889
|
+
<div class="sub">
|
|
1890
|
+
The address peers will be given, and the one thing here
|
|
1891
|
+
that cannot be taken back: a <code>.torrent</code> is
|
|
1892
|
+
served exactly as it was written, so this URL reaches
|
|
1893
|
+
everyone who ever fetches it. Filled in from
|
|
1894
|
+
<code>publishingUrl</code>, or from this request when
|
|
1895
|
+
there is none — worth correcting if the node answers on
|
|
1896
|
+
more than one name.
|
|
1897
|
+
</div>
|
|
1898
|
+
</div>
|
|
1870
1899
|
${toggle(
|
|
1871
1900
|
'publicDownload',
|
|
1872
1901
|
'Offer it as a download on the public page',
|
|
@@ -1927,10 +1956,25 @@
|
|
|
1927
1956
|
}
|
|
1928
1957
|
box.disabled = true;
|
|
1929
1958
|
try {
|
|
1959
|
+
// Sent only with the switch it belongs to, so editing the
|
|
1960
|
+
// field and then toggling something else cannot rewrite a
|
|
1961
|
+
// seed nobody was looking at.
|
|
1962
|
+
const typed =
|
|
1963
|
+
key === 'selfWebSeed' && want
|
|
1964
|
+
? pane.querySelector('#publish-base')?.value.trim()
|
|
1965
|
+
: undefined;
|
|
1930
1966
|
const result = await api(
|
|
1931
1967
|
`/api/torrents/${infoHash}/publish`,
|
|
1932
|
-
{
|
|
1968
|
+
{
|
|
1969
|
+
method: 'POST',
|
|
1970
|
+
body: { [key]: want, publishingUrl: typed || undefined },
|
|
1971
|
+
},
|
|
1933
1972
|
);
|
|
1973
|
+
// A seed URL nothing will rewrite later, published to a
|
|
1974
|
+
// network smaller than the swarm. Worth interrupting for:
|
|
1975
|
+
// by the time a peer fails to fetch it, the URL is in every
|
|
1976
|
+
// copy of the torrent.
|
|
1977
|
+
if (result.warning) toast(result.warning);
|
|
1934
1978
|
// The withdrawal is worth saying out loud. The others are
|
|
1935
1979
|
// visible in the checkbox that was just clicked.
|
|
1936
1980
|
if (result.withdrewWebSeed) {
|
|
@@ -4610,6 +4654,34 @@ Every piece is hashed against the ` +
|
|
|
4610
4654
|
await renderTokenEditor(body);
|
|
4611
4655
|
renderHookEditor(body, config, restartKeys);
|
|
4612
4656
|
|
|
4657
|
+
// The three switches the details panel offers, as import defaults.
|
|
4658
|
+
// Each has a blank option as well as yes and no, because unset is a
|
|
4659
|
+
// real answer here: it means the archive follows the node, and a row
|
|
4660
|
+
// that has never said anything must keep not saying it. See
|
|
4661
|
+
// publishingFor() -- the node's setting reaches everything that never
|
|
4662
|
+
// had one of its own, and that only works if "no opinion" is storable.
|
|
4663
|
+
const publishing = ['', 'node'];
|
|
4664
|
+
const publishingColumns = [
|
|
4665
|
+
{
|
|
4666
|
+
field: 'serveArchive',
|
|
4667
|
+
label: 'Serve file',
|
|
4668
|
+
boolean: true,
|
|
4669
|
+
options: [publishing, ['true', 'yes'], ['false', 'no']],
|
|
4670
|
+
},
|
|
4671
|
+
{
|
|
4672
|
+
field: 'selfWebSeed',
|
|
4673
|
+
label: 'Web seed',
|
|
4674
|
+
boolean: true,
|
|
4675
|
+
options: [publishing, ['true', 'yes'], ['false', 'no']],
|
|
4676
|
+
},
|
|
4677
|
+
{
|
|
4678
|
+
field: 'publicDownload',
|
|
4679
|
+
label: 'Listed',
|
|
4680
|
+
boolean: true,
|
|
4681
|
+
options: [publishing, ['true', 'yes'], ['false', 'no']],
|
|
4682
|
+
},
|
|
4683
|
+
];
|
|
4684
|
+
|
|
4613
4685
|
renderRowEditor({
|
|
4614
4686
|
into: body,
|
|
4615
4687
|
key: 'locations',
|
|
@@ -4703,6 +4775,7 @@ Every piece is hashed against the ` +
|
|
|
4703
4775
|
placeholder: 'for ever',
|
|
4704
4776
|
number: true,
|
|
4705
4777
|
},
|
|
4778
|
+
...publishingColumns,
|
|
4706
4779
|
],
|
|
4707
4780
|
rows: config.watch ?? [],
|
|
4708
4781
|
footnote:
|
|
@@ -4811,6 +4884,7 @@ Every piece is hashed against the ` +
|
|
|
4811
4884
|
placeholder: 'for ever',
|
|
4812
4885
|
number: true,
|
|
4813
4886
|
},
|
|
4887
|
+
...publishingColumns,
|
|
4814
4888
|
],
|
|
4815
4889
|
rows: config.sources ?? [],
|
|
4816
4890
|
checkNow: '/api/sources/check',
|
|
@@ -4935,6 +5009,7 @@ Every piece is hashed against the ` +
|
|
|
4935
5009
|
['false', 'no'],
|
|
4936
5010
|
],
|
|
4937
5011
|
},
|
|
5012
|
+
...publishingColumns,
|
|
4938
5013
|
],
|
|
4939
5014
|
rows: following.filter((row) => !isPeer(row)),
|
|
4940
5015
|
checkNow: '/api/subscriptions/refresh',
|
|
@@ -5009,6 +5084,7 @@ Every piece is hashed against the ` +
|
|
|
5009
5084
|
['false', 'no'],
|
|
5010
5085
|
],
|
|
5011
5086
|
},
|
|
5087
|
+
...publishingColumns,
|
|
5012
5088
|
],
|
|
5013
5089
|
rows: following.filter(isPeer),
|
|
5014
5090
|
checkNow: '/api/subscriptions/refresh',
|