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 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
 
@@ -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
- | `selfWebSeed` | `false` | publish this node as a web seed for archives it holds |
322
- | `publicDownload` | `false` | offer them as downloads on the public catalogue page |
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. Set [`publicUrl`](#publicurl), or turn
362
- the switch on from the console, where the request itself names the node. Without
363
- either, the setting is refused rather than guessed at: a guessed web seed URL is
364
- published and then followed.
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 same three-level rule as [`md5`](#md5). The node's setting is the default; a
381
- [watched folder](#watched-folders) or a [scheduled source](#scheduled-sources)
382
- may carry its own; and any individual archive can be switched in the console,
383
- under **HTTP sources** in its details. An archive that says nothing goes on
384
- following the node, so changing the node's answer reaches every archive that
385
- never had one of its own.
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.50.1",
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 { normalizeCategories, publishingFor } from './catalog.js';
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: publishingFor(entry, config),
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
- req.params.infoHash,
1142
- body,
1143
- // A node with no publicUrl still has to name itself to be a web
1144
- // seed, and the request it is being asked on is the best evidence
1145
- // available — the same reasoning the TileJSON already uses. Given
1146
- // the public port, never the admin one: a seed URL on a listener
1147
- // bound to localhost is a URL no peer can reach.
1148
- { baseUrl: baseUrl(req) },
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 = String(options.baseUrl ?? this.#config.publicUrl ?? '')
2875
- .trim()
2876
- .replace(/\/+$/, '');
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 publicUrl, or pass a base URL',
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.
@@ -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
@@ -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
- // location.origin, not the `base` renderDetail keeps: this runs in
1825
- // fillPane, which is a different function, and reaching for a
1826
- // binding that is not there is a ReferenceError nothing here would
1827
- // catch until somebody opened the tab.
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
- { method: 'POST', body: { [key]: want } },
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',