pmtiles-swarm 0.50.0 → 0.52.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,57 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.52.0
11
+ ### ✨ Features and improvements
12
+ - **`publishingUrl`, for the URLs that have to be permanent.** Almost every URL this node emits is
13
+ worked out per request, deliberately: a node answering on several domains should name itself as
14
+ whichever one was asked for, and leaving `publicUrl` unset is what allows that. A web seed is not
15
+ read once, though — it is written into the `.torrent` and the magnet, served byte for byte to
16
+ everyone who fetches either, and never rewritten — so it has to be one address rather than
17
+ whichever the last request happened to arrive on.
18
+
19
+ `publishingUrl` is that address, and nothing else. TileJSON, tile templates, `.torrent` links,
20
+ style URLs and the feeds go on naming whichever host the request arrived as, so the multi-domain
21
+ behaviour is intact. It is taken exactly as written, port and path included: a node listening on
22
+ 8090 behind a load balancer answering on 443 sets `https://swarm.example.org` and gets it, with
23
+ none of the port substitution that applies to a request reaching the node directly.
24
+
25
+ - **A "Published as" field beside the web seed switch**, prefilled with whatever the node would use
26
+ and editable until the moment the switch is turned on. Which is the point: it is the one URL here
27
+ that cannot be taken back, so it should be readable — and correctable — before it is.
28
+
29
+ Resolution order is deliberate: the field, then `publishingUrl`, then `publicUrl`, then the
30
+ request. With none of them the setting is refused rather than invented.
31
+
32
+ ## 0.51.0
33
+ ### ✨ Features and improvements
34
+ - **A web seed URL that no peer could fetch is caught before it is published.** Nothing rewrites a
35
+ web seed afterwards — a `.torrent` is served byte for byte as it was written, so the URL that goes
36
+ in is the URL every peer receives for as long as the torrent exists. A loopback address is now
37
+ refused outright, since `127.0.0.1` names the machine asking and every peer given it would try to
38
+ fetch the archive from itself and retry for ever. A private address, or a hostname with no domain
39
+ in it, is published with a warning rather than blocked: a node syncing to its own peers across a
40
+ LAN is a real arrangement, and the internal address is the right answer there.
41
+
42
+ ### 🐞 Bug fixes
43
+ - **The HTTP sources tab showed the admin port beside the serve switch.** The console built the URL
44
+ from the address in the browser's bar, and the console is served from the admin listener — so the
45
+ one port that is not for the public was the one displayed next to a switch that publishes a URL to
46
+ the whole swarm. It now asks the node, which already answers this correctly everywhere else. What
47
+ was actually written into the `.torrent` and the magnet was right throughout; only the label was
48
+ wrong.
49
+
50
+ ## 0.50.1
51
+ ### ✨ Features and improvements
52
+
53
+ ### 🐞 Bug fixes
54
+ - **The HTTP sources tab threw instead of opening.** The three switches were built with the base URL
55
+ that `renderDetail` keeps, and they are rendered by `fillPane`, which is a different function with
56
+ no sight of it — a `ReferenceError` the moment the tab was clicked. The console is one module
57
+ inside an HTML file, so there is nothing to import and nothing to lint: `node --check` accepts a
58
+ binding reached across a function boundary and says nothing. A test now compares what `fillPane`
59
+ uses against what only `renderDetail` declares.
60
+
10
61
  ## 0.50.0
11
62
  ### ✨ Features and improvements
12
63
  - **Three switches for what a node offers of an archive's own bytes**, on the node, on a watched
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.50.0",
3
+ "version": "0.52.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 {
@@ -2869,18 +2869,31 @@ export class Library {
2869
2869
  // so nothing would ever have published it.
2870
2870
  const published = entry.selfWebSeedUrl ?? null;
2871
2871
  let webSeed = published;
2872
+ let warning = null;
2872
2873
 
2873
2874
  if (after.selfWebSeed && !published) {
2874
- const base = String(options.baseUrl ?? this.#config.publicUrl ?? '')
2875
- .trim()
2876
- .replace(/\/+$/, '');
2875
+ const base = publishingBase({
2876
+ // Given outright beats the node's own answer, which beats the request
2877
+ // this happens to have arrived on. See publishingBase().
2878
+ explicit: options.publishingUrl,
2879
+ config: this.#config,
2880
+ requestBase: options.baseUrl,
2881
+ });
2877
2882
  if (!base) {
2878
2883
  throw new Error(
2879
2884
  'cannot publish this node as a web seed without knowing its own ' +
2880
- 'URL: set publicUrl, or pass a base URL',
2885
+ 'URL: set publishingUrl, or give one with the request',
2881
2886
  );
2882
2887
  }
2883
2888
  webSeed = `${base}/archives/${infoHash}/archive.pmtiles`;
2889
+ // Checked before it goes anywhere. Nothing rewrites a web seed once it
2890
+ // is in a .torrent -- the file is served byte for byte to everyone who
2891
+ // asks -- so a URL that cannot work is not a mistake that gets corrected
2892
+ // on the next request. It is distributed and then retried for ever.
2893
+ const reach = reachability(webSeed);
2894
+ if (!reach.ok) throw new Error(reach.why);
2895
+ if (reach.warning) console.warn(`[web seed] ${reach.warning}`);
2896
+ warning = reach.warning ?? null;
2884
2897
  await this.addWebSeeds(infoHash, [webSeed]);
2885
2898
  // Only the field. put() merges, so spreading the entry captured before
2886
2899
  // addWebSeeds would write the old webSeeds and the old magnet back over
@@ -2900,6 +2913,9 @@ export class Library {
2900
2913
  return {
2901
2914
  ...after,
2902
2915
  webSeed,
2916
+ // Published anyway, and said out loud. A node syncing to its own peers
2917
+ // across a LAN is a real arrangement and not this code's to overrule.
2918
+ warning,
2903
2919
  // Named so a caller can warn about the one change that is not merely a
2904
2920
  // setting moving: this URL is already in the hands of every peer that
2905
2921
  // holds the torrent, and they will go on trying it for a while.
@@ -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,7 +1828,15 @@
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
- const archiveUrl = `${base}/archives/${entry.infoHash}/archive.pmtiles`;
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.
1837
+ const archiveUrl =
1838
+ on.url ??
1839
+ `${location.origin}/archives/${entry.infoHash}/archive.pmtiles`;
1825
1840
 
1826
1841
  // Three switches rather than one, because they are three separate
1827
1842
  // exposures. Serving the file to a reader that was handed the URL,
@@ -1862,6 +1877,25 @@
1862
1877
  while.`,
1863
1878
  !on.serveArchive,
1864
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>
1865
1899
  ${toggle(
1866
1900
  'publicDownload',
1867
1901
  'Offer it as a download on the public page',
@@ -1922,10 +1956,25 @@
1922
1956
  }
1923
1957
  box.disabled = true;
1924
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;
1925
1966
  const result = await api(
1926
1967
  `/api/torrents/${infoHash}/publish`,
1927
- { method: 'POST', body: { [key]: want } },
1968
+ {
1969
+ method: 'POST',
1970
+ body: { [key]: want, publishingUrl: typed || undefined },
1971
+ },
1928
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);
1929
1978
  // The withdrawal is worth saying out loud. The others are
1930
1979
  // visible in the checkbox that was just clicked.
1931
1980
  if (result.withdrewWebSeed) {