pmtiles-swarm 0.49.0 → 0.50.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,38 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.50.0
11
+ ### ✨ Features and improvements
12
+ - **Three switches for what a node offers of an archive's own bytes**, on the node, on a watched
13
+ folder, on a scheduled source, and on any individual archive from **HTTP sources** in its details.
14
+ They are separate because they are three different exposures, and a node can reasonably want any
15
+ one of them without the others:
16
+
17
+ - `serveArchive` — whether `/archives/<infohash>/archive.pmtiles` answers at all. This is the one
18
+ that decides whether a stranger who knows an infohash can pull 700 GiB off the box.
19
+ - `selfWebSeed` — whether this node's own URL goes into the torrent's `url-list`, so every peer
20
+ holding the torrent fetches from here. Turning it on writes the URL into the `.torrent` and the
21
+ magnet; turning it off takes that URL back out.
22
+ - `publicDownload` — whether the public catalogue page offers it as a download. Serving a file to a
23
+ reader that was handed the URL and advertising it to every visitor are different decisions.
24
+
25
+ The last two are read as off wherever the first is, whatever the record says. A web seed URL that
26
+ answers `403` is worse than no web seed, because a client spends its retries on it, and a download
27
+ link that `403`s is worse than no link. An archive that says nothing about a setting goes on
28
+ following the node, so changing the node's answer reaches everything that never had one of its own.
29
+
30
+ - **`DELETE /api/torrents/<infohash>/webseeds`** drops web seeds, the same rewrite as adding them and
31
+ safe for the same reason: `url-list` sits outside the info dictionary, so the infohash — and every
32
+ magnet and peer depending on it — is untouched.
33
+
34
+ ### 🐞 Bug fixes
35
+ - **Serving whole archives over HTTP is no longer on for everyone.** 0.48.0 added
36
+ `/archives/<infohash>/archive.pmtiles` and left it answering for every complete archive, on every
37
+ node, to anyone who knew an infohash. Everything else this node publishes is either small — TileJSON,
38
+ a `.torrent`, a feed — or metered by the request, one tile at a time, so turning a node on had never
39
+ meant offering its disk to strangers. It does not now either: `serveArchive` defaults to off and
40
+ both range endpoints answer `403` until it is set.
41
+
10
42
  ## 0.49.0
11
43
  ### ✨ Features and improvements
12
44
  - **The current build of a category can be read as a file.** `GET /latest/<category>/archive.pmtiles`
package/README.md CHANGED
@@ -756,6 +756,8 @@ which the endpoint answers 501.
756
756
  | `POST` | `/api/torrents/:infoHash/recheck` | Hash what is on disk again and believe the result — for an archive whose progress and whose files disagree |
757
757
  | `POST` | `/api/torrents/:infoHash/pause`, `/resume` | Stop offering it, without forgetting it |
758
758
  | `POST` | `/api/torrents/:infoHash/webseeds` | Add web seeds — does not change the infohash |
759
+ | `DELETE` | `/api/torrents/:infoHash/webseeds` | Drop web seeds — likewise |
760
+ | `POST` | `/api/torrents/:infoHash/publish` | What this node offers of the archive itself: `serveArchive`, `selfWebSeed`, `publicDownload` |
759
761
  | `POST` | `/api/torrents/:infoHash/warm` | Pre-fetch a region (`GET` for progress, `DELETE` to cancel) |
760
762
  | `DELETE` | `/api/torrents/:infoHash/cache` | Reclaim cached pieces, keep the archive |
761
763
  | `POST` | `/api/torrents/:infoHash/check` | Has the source changed since the torrent was made? |
@@ -783,11 +785,11 @@ which the endpoint answers 501.
783
785
  | `GET` | `/health` | 200 when this node can serve, 503 when its engine cannot — **public**, no credential, for a load balancer |
784
786
  | `GET` | `/archives/:infoHash/ready` | Whether this node can serve _this_ archive yet: 200 ready, 503 not yet, 415 never — **public** |
785
787
  | `GET` | `/archives/:infoHash/archive.torrent` | The `.torrent` a torrent-aware client joins with — **public** |
786
- | `GET` | `/archives/:infoHash/archive.pmtiles` | The archive itself, by byte range — what any PMTiles reader wants, and a valid web seed. Complete archives only — **public** |
788
+ | `GET` | `/archives/:infoHash/archive.pmtiles` | The archive itself, by byte range — what any PMTiles reader wants, and a valid web seed. Complete archives only, and only where [`serveArchive`](docs/configuration.md#servearchive) is on — **public** |
787
789
  | `GET` | `/archives/:infoHash/preview` | Map preview for one archive — admin, not public |
788
790
  | `GET` | `/latest/` | Every category and the endpoints that resolve to its newest build — **public**, the index of everything below |
789
791
  | `GET` | `/latest/:category/tiles.json`, `/:name.torrent`, `/magnet` | The newest build in a category — **public**. The torrent name is yours to choose, so a link can read `planetiler-openmaptiles-latest.torrent`; it redirects to the immutable URL, which names the download after the build it actually is |
790
- | `GET` | `/latest/:category/archive.pmtiles` | The newest build in a category as a file, by byte range — **public**. What to point a PMTiles reader at when you want "whichever is current" rather than one build |
792
+ | `GET` | `/latest/:category/archive.pmtiles` | The newest build in a category as a file, by byte range — **public**, and gated by [`serveArchive`](docs/configuration.md#servearchive). What to point a PMTiles reader at when you want "whichever is current" rather than one build |
791
793
  | `GET` | `/feed.xml`, `/feed/:category.xml`, `/latest/:category.xml` | RSS — **public** |
792
794
 
793
795
  Everything under `/api/` is guarded once a credential is configured; tiles, TileJSON and the feeds
@@ -13,6 +13,7 @@ Some settings only take effect on restart. Those are marked **restart**.
13
13
  - [Storage](#storage)
14
14
  - [Engines](#engines)
15
15
  - [Creating torrents](#creating-torrents)
16
+ - [Offering the archive file itself](#offering-the-archive-file-itself)
16
17
  - [Trackers](#trackers)
17
18
  - [Feeds](#feeds)
18
19
  - [Watched folders](#watched-folders)
@@ -312,6 +313,77 @@ idled. Use **Pause all** for the other half.
312
313
  It lives in the configuration rather than in memory, so a node taken out of
313
314
  rotation stays out across the restart you were probably about to do.
314
315
 
316
+ ## Offering the archive file itself
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 |
323
+
324
+ Three switches rather than one, because they are three different exposures and a
325
+ node can reasonably want any of them without the others.
326
+
327
+ ### `serveArchive`
328
+
329
+ Off by default. This is the archive as a file, by byte range — which is what
330
+ every PMTiles reader actually wants, and what makes this node usable as an origin
331
+ without a copy of the file somewhere else.
332
+
333
+ It is off by default because it is the only thing a node publishes that is not
334
+ either small or metered by the request. TileJSON, a `.torrent`, a feed: kilobytes.
335
+ A tile: one tile. This is up to 700 GiB to anyone who knows an infohash, so
336
+ turning a node on has never meant offering its disk to strangers, and still does
337
+ not.
338
+
339
+ With it on, both range endpoints answer:
340
+
341
+ ```
342
+ GET /archives/<infohash>/archive.pmtiles one build, immutable, cached for a year
343
+ GET /latest/<category>/archive.pmtiles whichever is current, with an ETag
344
+ ```
345
+
346
+ With it off, both answer `403`.
347
+
348
+ ### `selfWebSeed`
349
+
350
+ Writes this node's own `archive.pmtiles` URL into the torrent's `url-list`, so
351
+ every peer holding the torrent fetches from here over HTTP. A web seed is the
352
+ difference between a cold tile taking tens of seconds and taking under one, and
353
+ it is what makes a brand-new archive usable before it has any peers at all.
354
+
355
+ It is also an open invitation, which is why it is separate: a seed URL is
356
+ followed by everyone who holds the torrent, not only by people who came to this
357
+ node. Turning it off takes the URL back out of the `.torrent` and the magnet —
358
+ but peers already holding either keep trying it until they refresh, so this
359
+ withdraws an advertisement rather than closing a door.
360
+
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.
365
+
366
+ ### `publicDownload`
367
+
368
+ Adds a **download** link to the public catalogue page. Separate from
369
+ `serveArchive` because serving a file to a reader that was handed the URL and
370
+ advertising it to every visitor are different decisions — the endpoint can exist
371
+ for a style or a peer without being put in front of a browser.
372
+
373
+ Both `selfWebSeed` and `publicDownload` are read as off wherever `serveArchive`
374
+ is off, whatever the file says. A web seed URL that answers `403` is worse than no
375
+ web seed, because a client spends its retries on it, and a download link that
376
+ `403`s is worse than no link.
377
+
378
+ ### Per archive, per folder, per source
379
+
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.
386
+
315
387
  ## Trackers
316
388
 
317
389
  `trackers` is baked into every torrent this node creates. It defaults to the
@@ -372,7 +444,8 @@ their own name. Archives with no category are excluded whenever this is set.
372
444
  ## Watched folders
373
445
 
374
446
  `watch` is a list of `{ path, category, match, webSeedBase, publishDir, sparse,
375
- latestLink, latestLinkType, keep, keepDays, md5 }`.
447
+ latestLink, latestLinkType, keep, keepDays, md5, serveArchive, selfWebSeed,
448
+ publicDownload }`.
376
449
 
377
450
  | field | |
378
451
  | ------------------- | ------------------------------------------------------------------------------------- |
@@ -383,6 +456,9 @@ latestLink, latestLinkType, keep, keepDays, md5 }`.
383
456
  | `latestLinkType` | `'symbolic'` (default) or `'hard'` |
384
457
  | `keep` / `keepDays` | retire what the folder has outgrown |
385
458
  | `md5` | overrides the node's [`md5`](#md5) for this folder alone |
459
+ | `serveArchive` | overrides [`serveArchive`](#servearchive) for archives from this folder |
460
+ | `selfWebSeed` | overrides [`selfWebSeed`](#selfwebseed) for them |
461
+ | `publicDownload` | overrides [`publicDownload`](#publicdownload) for them |
386
462
 
387
463
  `publishDir` and `webSeedBase` together give every imported archive a working web
388
464
  seed, which is what makes a brand-new archive usable before any peer has a copy of
@@ -420,14 +496,15 @@ remove the newest build however old it gets. See
420
496
  `sources` is a list of upstreams that publish a new archive on a schedule. Each
421
497
  entry gives either a `url` template or an `index` directory:
422
498
 
423
- | field | |
424
- | ------------ | ------------------------------------------------------------------------------------------------------------- |
425
- | `url` | a template with the date in it — `{YYYYMMDD}`, `{YYYY-MM-DD}`, `{YYYY}`, `{MM}`, `{DD}` — expanded and probed |
426
- | `index` | a directory URL, listed and filtered |
427
- | `newest` | how many listed files an index source will consider. Defaults to 1 |
428
- | `at` | a time of day in UTC, or a list of them — `"03:30"` |
429
- | `everyHours` | an interval instead |
430
- | `md5` | overrides the node's [`md5`](#md5) for this source alone |
499
+ | field | |
500
+ | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
501
+ | `url` | a template with the date in it — `{YYYYMMDD}`, `{YYYY-MM-DD}`, `{YYYY}`, `{MM}`, `{DD}` — expanded and probed |
502
+ | `index` | a directory URL, listed and filtered |
503
+ | `newest` | how many listed files an index source will consider. Defaults to 1 |
504
+ | `at` | a time of day in UTC, or a list of them — `"03:30"` |
505
+ | `everyHours` | an interval instead |
506
+ | `md5` | overrides the node's [`md5`](#md5) for this source alone |
507
+ | `serveArchive`, `selfWebSeed`, `publicDownload` | override what this node offers of the archives this source fetches |
431
508
 
432
509
  Prefer a template where the naming is predictable: it asks a direct question,
433
510
  gets a direct answer, and needs the upstream to publish no listing at all.
@@ -121,6 +121,12 @@ GET /latest/{category}.xml a feed holding only the current build
121
121
  Point a style at `/latest/basemaps/tiles.json` and it keeps working across every
122
122
  rebuild, with no edit.
123
123
 
124
+ `archive.pmtiles` is the one of these that has to be asked for. It is off until
125
+ [`serveArchive`](configuration.md#servearchive) is set, on the node or on the
126
+ archive, because it is the only thing here that is neither small nor metered by
127
+ the request — everything else on this page is kilobytes or one tile, and this is
128
+ the whole file.
129
+
124
130
  **The tiles it names are still infohash URLs.** That is the whole point of the
125
131
  layering: this document is the only mutable thing in the system, and everything
126
132
  it refers to stays content-addressed and cached for a year. Pointing the tile
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.49.0",
3
+ "version": "0.50.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,7 @@ import {
13
13
  hashToken,
14
14
  isPublicSurface,
15
15
  } from './auth.js';
16
- import { normalizeCategories } from './catalog.js';
16
+ import { normalizeCategories, publishingFor } from './catalog.js';
17
17
  import { mutableMagnet, trackersFromMagnet } from './mutable.js';
18
18
  import { guessKind } from './library.js';
19
19
  import { QBittorrentEngine } from './engines/qbittorrent.js';
@@ -1005,7 +1005,17 @@ export function createApp({
1005
1005
  // being read through the swarm while serving thousands of tiles is a
1006
1006
  // different situation from one doing neither.
1007
1007
  const served = stats?.forArchive(entry.infoHash) ?? null;
1008
- res.json({ ...entry, status, reading, diskBytes, served });
1008
+ // Resolved rather than raw, because an entry that says nothing is not
1009
+ // "off" — it defers to the node, and the console has to show what is
1010
+ // actually happening rather than what this record happens to store.
1011
+ res.json({
1012
+ ...entry,
1013
+ status,
1014
+ reading,
1015
+ diskBytes,
1016
+ served,
1017
+ publishing: publishingFor(entry, config),
1018
+ });
1009
1019
  }),
1010
1020
  );
1011
1021
 
@@ -1097,6 +1107,54 @@ export function createApp({
1097
1107
  }),
1098
1108
  );
1099
1109
 
1110
+ // Removes web seeds from an archive already in circulation. The same
1111
+ // rewrite as adding one, and safe for the same reason: url-list sits outside
1112
+ // the info dictionary, so the infohash is untouched.
1113
+ app.delete(
1114
+ '/api/torrents/:infoHash/webseeds',
1115
+ route(async (req, res) => {
1116
+ const body = req.body ?? {};
1117
+ const urls = body.webSeeds ?? body.urls ?? body.url;
1118
+ try {
1119
+ const result = await library.removeWebSeeds(
1120
+ req.params.infoHash,
1121
+ Array.isArray(urls) ? urls : [urls],
1122
+ );
1123
+ res.json(result);
1124
+ } catch (error) {
1125
+ const status = /unknown archive/.test(error.message) ? 404 : 400;
1126
+ res.status(status).json({ error: error.message });
1127
+ }
1128
+ }),
1129
+ );
1130
+
1131
+ // What this node offers of one archive's own bytes over HTTP: whether it
1132
+ // serves the file at all, whether it publishes itself as a web seed for it,
1133
+ // and whether the public page offers it as a download. Three switches
1134
+ // because they are three exposures — see publishingFor in catalog.js.
1135
+ app.post(
1136
+ '/api/torrents/:infoHash/publish',
1137
+ route(async (req, res) => {
1138
+ const body = req.body ?? {};
1139
+ 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
+ );
1150
+ res.json(result);
1151
+ } catch (error) {
1152
+ const status = /unknown archive/.test(error.message) ? 404 : 400;
1153
+ res.status(status).json({ error: error.message });
1154
+ }
1155
+ }),
1156
+ );
1157
+
1100
1158
  // Stops a torrent without forgetting it. "Not right now" is a different
1101
1159
  // intention from "not any more", and remove was the only way to express
1102
1160
  // either.
@@ -1601,6 +1659,9 @@ export function createApp({
1601
1659
  webSeedBase: body.webSeedBase,
1602
1660
  allowUnknown: body.allowUnknown,
1603
1661
  md5: body.md5,
1662
+ serveArchive: body.serveArchive,
1663
+ selfWebSeed: body.selfWebSeed,
1664
+ publicDownload: body.publicDownload,
1604
1665
  webSeed: body.webSeed,
1605
1666
  };
1606
1667
 
@@ -1951,6 +2012,14 @@ export function createApp({
1951
2012
  magnet: entry.magnet,
1952
2013
  torrent: `${baseUrl(req)}/archives/${entry.infoHash}/archive.torrent`,
1953
2014
  webSeeds: entry.webSeeds ?? [],
2015
+ // Present only when this archive is offered as a download. Absent
2016
+ // otherwise rather than null: a public document should say what is
2017
+ // on offer, not enumerate what is being withheld.
2018
+ ...(publishingFor(entry, config).publicDownload
2019
+ ? {
2020
+ archive: `${baseUrl(req)}/archives/${entry.infoHash}/archive.pmtiles`,
2021
+ }
2022
+ : {}),
1954
2023
  pmtiles: entry.pmtiles,
1955
2024
  kind: entry.kind,
1956
2025
  md5: entry.md5,
@@ -2262,6 +2331,18 @@ export function createApp({
2262
2331
  const entry = newestIn(req.params.category, req);
2263
2332
  if (!entry) return res.status(404).json({ error: 'no such category' });
2264
2333
 
2334
+ // Whether this node hands out whole archives at all is the operator's
2335
+ // call, not a consequence of having the file. Everything else published
2336
+ // here is either small or metered by the request; this is up to 700 GiB to
2337
+ // anyone who knows an infohash, so it is asked for rather than assumed.
2338
+ if (!publishingFor(entry, config).serveArchive) {
2339
+ return res.status(403).json({
2340
+ error:
2341
+ 'this node does not serve archive files over HTTP. Set ' +
2342
+ '`serveArchive` on the node, or turn it on for this archive.',
2343
+ });
2344
+ }
2345
+
2265
2346
  if (entry.complete === false) {
2266
2347
  return res.status(409).json({
2267
2348
  error:
@@ -2595,6 +2676,18 @@ export function createApp({
2595
2676
  const entry = catalog.get(req.params.infoHash);
2596
2677
  if (!entry) return res.status(404).json({ error: 'not found' });
2597
2678
 
2679
+ // Whether this node hands out whole archives at all is the operator's
2680
+ // call, not a consequence of having the file. Everything else published
2681
+ // here is either small or metered by the request; this is up to 700 GiB to
2682
+ // anyone who knows an infohash, so it is asked for rather than assumed.
2683
+ if (!publishingFor(entry, config).serveArchive) {
2684
+ return res.status(403).json({
2685
+ error:
2686
+ 'this node does not serve archive files over HTTP. Set ' +
2687
+ '`serveArchive` on the node, or turn it on for this archive.',
2688
+ });
2689
+ }
2690
+
2598
2691
  if (entry.complete === false) {
2599
2692
  return res.status(409).json({
2600
2693
  error:
package/src/catalog.js CHANGED
@@ -21,6 +21,12 @@ import path from 'node:path';
21
21
  * @property {string} torrentPath - Generated .torrent on disk.
22
22
  * @property {string} magnet - Magnet URI for the current infohash.
23
23
  * @property {string[]} webSeeds - BEP 19 url-list entries.
24
+ * @property {boolean} [serveArchive] - Answer /archives/<hash>/archive.pmtiles
25
+ * for this archive. Unset defers to the node.
26
+ * @property {boolean} [selfWebSeed] - Publish this node's own archive URL in
27
+ * the torrent's url-list. Unset defers to the node.
28
+ * @property {boolean} [publicDownload] - Offer it as a download on the public
29
+ * catalogue page. Unset defers to the node.
24
30
  * @property {object} [pmtiles] - Header and metadata summary.
25
31
  * @property {object} [mutable] - BEP 46 identity: {publicKey, salt, seq}.
26
32
  * @property {string} [originMtime] - The archive's mtime on the node that built
@@ -51,6 +57,41 @@ export function normalizeCategories(source) {
51
57
  return [...new Set(clean)].sort();
52
58
  }
53
59
 
60
+ /**
61
+ * What this node offers of an archive's own bytes, over plain HTTP.
62
+ *
63
+ * Three separate decisions, because they are three separate exposures and
64
+ * conflating them takes the choice away from whoever runs the node:
65
+ *
66
+ * - `serveArchive` — whether `/archives/<hash>/archive.pmtiles` answers at
67
+ * all. This is the one that decides whether a stranger who knows an infohash
68
+ * can pull 700 GiB off the box.
69
+ * - `selfWebSeed` — whether this node's own URL is written into the torrent's
70
+ * `url-list`, so every peer in the swarm fetches from it.
71
+ * - `publicDownload` — whether the public catalogue page offers it as a
72
+ * download. Serving a file to a reader that already knows the URL and
73
+ * advertising it on a page are not the same act.
74
+ *
75
+ * The last two are ANDed with the first rather than merely defaulting from it.
76
+ * A node cannot be a web seed for a file it will not serve, and a download link
77
+ * that 403s is worse than no link — so a catalog edited by hand into that state
78
+ * is read as the safe thing rather than obeyed into an incoherent one.
79
+ * @param {object} [entry] - A catalog entry, whose fields win where set.
80
+ * @param {object} [config] - The node's defaults.
81
+ * @returns {{serveArchive: boolean, selfWebSeed: boolean, publicDownload: boolean}} - Resolved.
82
+ */
83
+ export function publishingFor(entry, config) {
84
+ const serveArchive = entry?.serveArchive ?? config?.serveArchive ?? false;
85
+ return {
86
+ serveArchive,
87
+ selfWebSeed:
88
+ serveArchive && (entry?.selfWebSeed ?? config?.selfWebSeed ?? false),
89
+ publicDownload:
90
+ serveArchive &&
91
+ (entry?.publicDownload ?? config?.publicDownload ?? false),
92
+ };
93
+ }
94
+
54
95
  /**
55
96
  * Orders two entries newest first.
56
97
  *
package/src/config.js CHANGED
@@ -165,6 +165,45 @@ const DEFAULTS = {
165
165
  * whether a second full read is worth it depends on what is being read.
166
166
  */
167
167
  md5: false,
168
+ /**
169
+ * Answer `/archives/<infohash>/archive.pmtiles` — the archive itself, by
170
+ * byte range, which is what every PMTiles reader actually wants.
171
+ *
172
+ * Off by default, and deliberately so: this is the whole file, and an
173
+ * archive here can be 700 GiB. Everything else this node publishes is either
174
+ * small (TileJSON, a .torrent, a feed) or metered by the request (one tile),
175
+ * so turning a node on has never meant offering its disk to anyone who knows
176
+ * an infohash. This would, so it is asked for rather than assumed.
177
+ *
178
+ * The node's answer, not the only one: a watched folder, a scheduled source
179
+ * or an individual archive may carry its own, and is obeyed either way.
180
+ */
181
+ serveArchive: false,
182
+ /**
183
+ * Write this node's own archive URL into the torrent's `url-list`, so every
184
+ * peer in the swarm can fetch from it over HTTP.
185
+ *
186
+ * A web seed is the difference between a cold tile taking tens of seconds
187
+ * and taking under one, and it is what makes an archive usable before it has
188
+ * any peers at all. It is also an open invitation: a seed URL is followed by
189
+ * everyone who holds the torrent, not only by people who visit this node.
190
+ *
191
+ * Means nothing without `serveArchive`, and is read as off without it — a
192
+ * web seed URL that refuses the request is worse than none, because a client
193
+ * spends its retries on it.
194
+ */
195
+ selfWebSeed: false,
196
+ /**
197
+ * Offer the archive as a download on the public catalogue page.
198
+ *
199
+ * Separate from `serveArchive` on purpose. Serving a file to a reader that
200
+ * was given the URL and advertising it on a page are different acts, and a
201
+ * node can reasonably want the first without the second: the endpoint exists
202
+ * for a style or a peer, and putting a 700 GiB link in front of every casual
203
+ * visitor is a different decision. Read as off without `serveArchive`, since
204
+ * a link that answers 403 is worse than no link.
205
+ */
206
+ publicDownload: false,
168
207
  /**
169
208
  * How long an unfinished download is kept before startup treats it as
170
209
  * abandoned. Until then, re-adding the same URL resumes it.
@@ -313,7 +352,8 @@ const DEFAULTS = {
313
352
  /**
314
353
  * Folders scanned for new archives. Each entry is `{ path, categories,
315
354
  * match, webSeedBase, publishDir, latestLink, latestLinkType, keep,
316
- * keepDays, sparse, md5 }` — see docs/configuration.md, "Watched folders".
355
+ * keepDays, sparse, md5, serveArchive, selfWebSeed, publicDownload }` — see
356
+ * docs/configuration.md, "Watched folders".
317
357
  */
318
358
  watch: [],
319
359
  /**
package/src/library.js CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  promote,
12
12
  suffixFor,
13
13
  } from './incomplete.js';
14
+ import { publishingFor } from './catalog.js';
14
15
  import { checkOrigin, fingerprintOrigin } from './origin.js';
15
16
  import { probePMTiles } from './pmtiles-probe.js';
16
17
  import {
@@ -333,6 +334,9 @@ export class Library {
333
334
  kind: identified.kind,
334
335
  sparse: options.sparse,
335
336
  md5: created.md5,
337
+ serveArchive: options.serveArchive,
338
+ selfWebSeed: options.selfWebSeed,
339
+ publicDownload: options.publicDownload,
336
340
  webSeeds: [...new Set(webSeeds)],
337
341
  seedOnly: true,
338
342
  });
@@ -1004,6 +1008,9 @@ export class Library {
1004
1008
  kind: identified.kind,
1005
1009
  sparse: options.sparse,
1006
1010
  md5: created.md5,
1011
+ serveArchive: options.serveArchive,
1012
+ selfWebSeed: options.selfWebSeed,
1013
+ publicDownload: options.publicDownload,
1007
1014
  // Whatever was asked for, plus the source when it may be published.
1008
1015
  // Falling back to the source alone dropped a caller's own list — which
1009
1016
  // is precisely the case where the source must not be published and a
@@ -2666,41 +2673,21 @@ export class Library {
2666
2673
  }
2667
2674
 
2668
2675
  /**
2669
- * Adds web seeds to an archive that already exists.
2676
+ * Rewrites an archive's web seed list, in the .torrent and everywhere else.
2670
2677
  *
2671
2678
  * This is safe on a torrent already in circulation. BEP 19's `url-list` is a
2672
2679
  * top-level key in the metainfo and the infohash covers only the `info`
2673
- * dictionary, so adding one leaves the infohash untouched — every magnet,
2680
+ * dictionary, so changing one leaves the infohash untouched — every magnet,
2674
2681
  * peer and published reference stays valid. The check below asserts that
2675
2682
  * rather than trusting it: if a rewrite ever did change the infohash, the
2676
2683
  * result would be a different torrent wearing the old one's name, which is
2677
2684
  * worth refusing loudly.
2678
- *
2679
- * Retrofitting matters because a web seed is the difference between a cold
2680
- * tile taking tens of seconds and taking well under one — and an archive
2681
- * published without one can be given one at any time.
2682
- * @param {string} infoHash - The archive.
2683
- * @param {string[]} urls - Web seed URLs to add.
2684
- * @param {object} [options] - Set `replace` to discard the existing list.
2685
- * @returns {Promise<{webSeeds: string[], live: boolean}>} - The new list, and
2686
- * whether the running torrent took them without a restart.
2685
+ * @param {object} entry - The catalog entry to rewrite.
2686
+ * @param {string[]} wanted - The complete new list.
2687
+ * @returns {Promise<{webSeeds: string[], parsed: object}>} - The new list and
2688
+ * the parsed torrent, for a caller that needs its infohash.
2687
2689
  */
2688
- async addWebSeeds(infoHash, urls, options = {}) {
2689
- const entry = this.#catalog.get(infoHash);
2690
- if (!entry) throw new Error('unknown archive');
2691
- if (!entry.torrentPath) {
2692
- throw new Error('no .torrent is stored for this archive');
2693
- }
2694
-
2695
- const wanted = (Array.isArray(urls) ? urls : [urls]).filter(Boolean);
2696
- if (wanted.length === 0) throw new Error('no web seeds given');
2697
-
2698
- for (const url of wanted) {
2699
- if (!/^https?:\/\//i.test(url)) {
2700
- throw new Error(`web seed must be an http(s) URL: ${url}`);
2701
- }
2702
- }
2703
-
2690
+ async #writeWebSeeds(entry, wanted) {
2704
2691
  const parseTorrentModule = await import('parse-torrent');
2705
2692
  const parse = parseTorrentModule.default;
2706
2693
  const { toTorrentFile } = parseTorrentModule;
@@ -2708,9 +2695,7 @@ export class Library {
2708
2695
  const original = await fs.readFile(entry.torrentPath);
2709
2696
  const parsed = await parse(new Uint8Array(original));
2710
2697
 
2711
- const merged = options.replace
2712
- ? [...new Set(wanted)]
2713
- : [...new Set([...(parsed.urlList ?? []), ...wanted])];
2698
+ const merged = [...new Set(wanted)];
2714
2699
  parsed.urlList = merged;
2715
2700
 
2716
2701
  const rebuilt = toTorrentFile(parsed);
@@ -2745,6 +2730,42 @@ export class Library {
2745
2730
  magnet: magnetFor(parsed, this.#config.trackers, merged),
2746
2731
  });
2747
2732
 
2733
+ return { webSeeds: merged, parsed };
2734
+ }
2735
+
2736
+ /**
2737
+ * Adds web seeds to an archive that already exists.
2738
+ *
2739
+ * Retrofitting matters because a web seed is the difference between a cold
2740
+ * tile taking tens of seconds and taking well under one — and an archive
2741
+ * published without one can be given one at any time.
2742
+ * @param {string} infoHash - The archive.
2743
+ * @param {string[]} urls - Web seed URLs to add.
2744
+ * @param {object} [options] - Set `replace` to discard the existing list.
2745
+ * @returns {Promise<{webSeeds: string[], live: boolean}>} - The new list, and
2746
+ * whether the running torrent took them without a restart.
2747
+ */
2748
+ async addWebSeeds(infoHash, urls, options = {}) {
2749
+ const entry = this.#catalog.get(infoHash);
2750
+ if (!entry) throw new Error('unknown archive');
2751
+ if (!entry.torrentPath) {
2752
+ throw new Error('no .torrent is stored for this archive');
2753
+ }
2754
+
2755
+ const wanted = (Array.isArray(urls) ? urls : [urls]).filter(Boolean);
2756
+ if (wanted.length === 0) throw new Error('no web seeds given');
2757
+
2758
+ for (const url of wanted) {
2759
+ if (!/^https?:\/\//i.test(url)) {
2760
+ throw new Error(`web seed must be an http(s) URL: ${url}`);
2761
+ }
2762
+ }
2763
+
2764
+ const { webSeeds } = await this.#writeWebSeeds(
2765
+ entry,
2766
+ options.replace ? wanted : [...(entry.webSeeds ?? []), ...wanted],
2767
+ );
2768
+
2748
2769
  // Where the engine can take a seed at runtime, the node benefits now;
2749
2770
  // where it cannot, peers still get it from the rewritten .torrent and this
2750
2771
  // node picks it up when the torrent is next added.
@@ -2758,7 +2779,132 @@ export class Library {
2758
2779
  live = results.every(Boolean);
2759
2780
  }
2760
2781
 
2761
- return { webSeeds: merged, live };
2782
+ return { webSeeds, live };
2783
+ }
2784
+
2785
+ /**
2786
+ * Drops web seeds from an archive, leaving everything else about it alone.
2787
+ *
2788
+ * The running engine is not told. libtorrent has no "forget this url seed"
2789
+ * that every version answers to, and the consequence of it keeping one is
2790
+ * bounded — it retries a URL that refuses and gives up on it. What matters
2791
+ * is that the .torrent and the magnet stop handing the URL to anybody new,
2792
+ * which is what this does.
2793
+ * @param {string} infoHash - The archive.
2794
+ * @param {string[]} urls - Web seed URLs to drop. Unknown ones are ignored.
2795
+ * @returns {Promise<{webSeeds: string[], dropped: string[]}>} - What is left,
2796
+ * and what went.
2797
+ */
2798
+ async removeWebSeeds(infoHash, urls) {
2799
+ const entry = this.#catalog.get(infoHash);
2800
+ if (!entry) throw new Error('unknown archive');
2801
+ if (!entry.torrentPath) {
2802
+ throw new Error('no .torrent is stored for this archive');
2803
+ }
2804
+
2805
+ const going = new Set(
2806
+ (Array.isArray(urls) ? urls : [urls]).filter(Boolean),
2807
+ );
2808
+ const held = entry.webSeeds ?? [];
2809
+ const dropped = held.filter((url) => going.has(url));
2810
+ if (dropped.length === 0) return { webSeeds: held, dropped: [] };
2811
+
2812
+ const { webSeeds } = await this.#writeWebSeeds(
2813
+ entry,
2814
+ held.filter((url) => !going.has(url)),
2815
+ );
2816
+ return { webSeeds, dropped };
2817
+ }
2818
+
2819
+ /**
2820
+ * Sets what this node offers of one archive's own bytes over HTTP.
2821
+ *
2822
+ * Three separate switches — see `publishingFor` in catalog.js for why they
2823
+ * are separate — and one side effect: turning `selfWebSeed` on writes this
2824
+ * node's own archive URL into the torrent's `url-list`, and turning it off
2825
+ * takes that URL back out. The URL used is remembered, because the base can
2826
+ * change underneath a node and removing "whatever we would build today"
2827
+ * would leave yesterday's URL in the torrent for ever.
2828
+ *
2829
+ * Turning `serveArchive` off takes the other two with it, and that is not a
2830
+ * quiet tidy-up: it means a URL already handed to every peer holding the
2831
+ * torrent stops answering. The caller is told what was withdrawn so it can
2832
+ * say so.
2833
+ * @param {string} infoHash - The archive.
2834
+ * @param {object} changes - Any of the three, as booleans. Anything else is
2835
+ * ignored, so a caller may send only what it is changing.
2836
+ * @param {object} [options] - `baseUrl` for building the web seed URL.
2837
+ * @returns {Promise<object>} - The resolved settings and what changed.
2838
+ */
2839
+ async setPublishing(infoHash, changes = {}, options = {}) {
2840
+ const entry = this.#catalog.get(infoHash);
2841
+ if (!entry) throw new Error('unknown archive');
2842
+
2843
+ // Only what was actually asked for is recorded. An archive that says
2844
+ // nothing about a setting goes on deferring to the node, which is what
2845
+ // makes changing the node's answer reach the archives that never had one
2846
+ // of their own.
2847
+ const wanted = { ...entry };
2848
+ for (const key of ['serveArchive', 'selfWebSeed', 'publicDownload']) {
2849
+ if (typeof changes[key] === 'boolean') wanted[key] = changes[key];
2850
+ }
2851
+
2852
+ // With serving off the other two cannot be true, and writing that down
2853
+ // matters more than it looks: left as a latent `true`, either would spring
2854
+ // back the moment serving was turned on again — re-publishing this node as
2855
+ // a web seed, or re-listing a 700 GiB download, as a side effect of a
2856
+ // decision about something else.
2857
+ if (!publishingFor(wanted, this.#config).serveArchive) {
2858
+ wanted.selfWebSeed = false;
2859
+ wanted.publicDownload = false;
2860
+ }
2861
+ const after = publishingFor(wanted, this.#config);
2862
+ await this.#catalog.put(wanted);
2863
+
2864
+ // Driven by what is on record rather than by the transition, so calling
2865
+ // this twice does nothing the second time and calling it on an archive
2866
+ // that was created with the setting already on still writes the seed. A
2867
+ // transition test looked equivalent and was not: an import that inherits
2868
+ // `selfWebSeed: true` from the node has no "before" in which it was off,
2869
+ // so nothing would ever have published it.
2870
+ const published = entry.selfWebSeedUrl ?? null;
2871
+ let webSeed = published;
2872
+
2873
+ if (after.selfWebSeed && !published) {
2874
+ const base = String(options.baseUrl ?? this.#config.publicUrl ?? '')
2875
+ .trim()
2876
+ .replace(/\/+$/, '');
2877
+ if (!base) {
2878
+ throw new Error(
2879
+ 'cannot publish this node as a web seed without knowing its own ' +
2880
+ 'URL: set publicUrl, or pass a base URL',
2881
+ );
2882
+ }
2883
+ webSeed = `${base}/archives/${infoHash}/archive.pmtiles`;
2884
+ await this.addWebSeeds(infoHash, [webSeed]);
2885
+ // Only the field. put() merges, so spreading the entry captured before
2886
+ // addWebSeeds would write the old webSeeds and the old magnet back over
2887
+ // the ones it had just rewritten -- the seed would appear to be added
2888
+ // and then vanish in the same call.
2889
+ await this.#catalog.put({ infoHash, selfWebSeedUrl: webSeed });
2890
+ } else if (!after.selfWebSeed && published) {
2891
+ // Whatever was actually published, which is not necessarily the URL this
2892
+ // node would build for itself today: the base can change underneath a
2893
+ // node, and removing "whatever we would say now" would leave yesterday's
2894
+ // URL in the torrent for ever.
2895
+ await this.removeWebSeeds(infoHash, [published]);
2896
+ await this.#catalog.put({ infoHash, selfWebSeedUrl: null });
2897
+ webSeed = null;
2898
+ }
2899
+
2900
+ return {
2901
+ ...after,
2902
+ webSeed,
2903
+ // Named so a caller can warn about the one change that is not merely a
2904
+ // setting moving: this URL is already in the hands of every peer that
2905
+ // holds the torrent, and they will go on trying it for a while.
2906
+ withdrewWebSeed: Boolean(published) && !after.selfWebSeed,
2907
+ };
2762
2908
  }
2763
2909
 
2764
2910
  /**
@@ -2919,7 +3065,7 @@ export class Library {
2919
3065
  mode: details.mode ?? 'mirror',
2920
3066
  });
2921
3067
 
2922
- return this.#catalog.put({
3068
+ const entry = await this.#catalog.put({
2923
3069
  infoHash: created.infoHash,
2924
3070
  name: created.name,
2925
3071
  size: created.size,
@@ -2939,12 +3085,37 @@ export class Library {
2939
3085
  // Left undefined unless asked for, so the format-based default applies
2940
3086
  // and a later change to that default reaches existing archives.
2941
3087
  sparse: details.sparse,
3088
+ // Left undefined unless the import asked for one, so the node's own
3089
+ // answer applies and a later change to it reaches every archive that
3090
+ // never had an opinion.
3091
+ serveArchive: details.serveArchive,
3092
+ selfWebSeed: details.selfWebSeed,
3093
+ publicDownload: details.publicDownload,
2942
3094
  mode: details.mode ?? 'mirror',
2943
3095
  retainedAt: created.retainedAt,
2944
3096
  origin,
2945
3097
  originMtime,
2946
3098
  stale: false,
2947
3099
  });
3100
+
3101
+ // Publishing this node as a web seed is the one of the three with an
3102
+ // effect beyond a stored boolean: it writes a URL into the .torrent and
3103
+ // the magnet. Done here so a watched folder and an RSS subscription get it
3104
+ // without each having to know, and warned rather than thrown because an
3105
+ // import that produced a good archive should not be failed by a seed URL
3106
+ // that could not be built.
3107
+ if (publishingFor(entry, this.#config).selfWebSeed) {
3108
+ try {
3109
+ await this.setPublishing(entry.infoHash, {});
3110
+ return this.#catalog.get(entry.infoHash) ?? entry;
3111
+ } catch (error) {
3112
+ console.warn(
3113
+ `[web seed] ${created.name} is not published as a web seed by this ` +
3114
+ `node: ${error.message}`,
3115
+ );
3116
+ }
3117
+ }
3118
+ return entry;
2948
3119
  }
2949
3120
  }
2950
3121
 
package/src/sources.js CHANGED
@@ -413,6 +413,9 @@ export class ScheduledSourceManager {
413
413
  // and one publishing a 700 GiB planet nightly is not worth reading
414
414
  // twice. Unset inherits the node's `md5`.
415
415
  md5: source.md5,
416
+ serveArchive: source.serveArchive,
417
+ selfWebSeed: source.selfWebSeed,
418
+ publicDownload: source.publicDownload,
416
419
  retain: source.retain !== false,
417
420
  // Left undefined the library decides, which is to publish the URL
418
421
  // unless it carries credentials. Set explicitly it is obeyed either
@@ -514,6 +517,9 @@ export class ScheduledSourceManager {
514
517
  addTrackers: source.addTrackers,
515
518
  pieceLength: source.pieceLength,
516
519
  md5: source.md5,
520
+ serveArchive: source.serveArchive,
521
+ selfWebSeed: source.selfWebSeed,
522
+ publicDownload: source.publicDownload,
517
523
  retain: source.retain !== false,
518
524
  webSeed: source.webSeed,
519
525
  webSeeds: source.webSeeds,
package/src/watch.js CHANGED
@@ -224,6 +224,10 @@ export class WatchManager {
224
224
  // taking a nightly planet build cannot, whatever the node's default
225
225
  // says. Left unset the node's `md5` decides, as it always did.
226
226
  md5: folder.md5,
227
+ // Same three-level rule as md5: unset here means the node decides.
228
+ serveArchive: folder.serveArchive,
229
+ selfWebSeed: folder.selfWebSeed,
230
+ publicDownload: folder.publicDownload,
227
231
  comment: folder.comment,
228
232
  // Marks this as the folder's, so retention below has a family to work
229
233
  // within and nothing outside it can be caught up in one.
@@ -377,6 +377,21 @@
377
377
  button.state { border-color: var(--line); color: var(--muted); }
378
378
  button.speed.on,
379
379
  button.state.on { border-color: var(--warn); color: var(--warn); }
380
+ /* The three switches on the HTTP sources tab. Decisions about exposure
381
+ rather than settings, so each gets room to say what it does instead of
382
+ sitting in a row of bare labels. Ordinary .choice labels underneath —
383
+ these rules only undo the parts of that written for a one-line one. */
384
+ .publish { display: grid; gap: 0.7rem; margin-bottom: 1.25rem; }
385
+ /* .choice centres the box against its label, which is right for one line
386
+ and wrong for four: the box drifts down beside the middle of the hint. */
387
+ .publish label { align-items: flex-start; }
388
+ .publish input { margin-top: 0.25rem; }
389
+ .publish .sub { display: block; margin-top: 0.15rem; }
390
+ .publish code { word-break: break-all; }
391
+ /* A switch whose gate is off. Dimmed rather than removed: taking it out
392
+ would rearrange the panel under a click, and would leave no sign of
393
+ why the option had gone. */
394
+ .publish input:disabled + span { opacity: 0.55; }
380
395
  </style>
381
396
  </head>
382
397
  <body>
@@ -1802,7 +1817,61 @@
1802
1817
 
1803
1818
  if (name === 'sources') {
1804
1819
  const seeds = entry.webSeeds ?? [];
1820
+ // Resolved by the node, not read off the record: an archive that
1821
+ // says nothing defers to the node's own answer, and what belongs
1822
+ // in a checkbox is what is actually happening.
1823
+ const on = entry.publishing ?? {};
1824
+ const archiveUrl = `${base}/archives/${entry.infoHash}/archive.pmtiles`;
1825
+
1826
+ // Three switches rather than one, because they are three separate
1827
+ // exposures. Serving the file to a reader that was handed the URL,
1828
+ // putting that URL in front of every peer in the swarm, and
1829
+ // advertising it to every visitor of the public page are different
1830
+ // decisions, and a node can reasonably want any one without the
1831
+ // others.
1832
+ const toggle = (key, label, hint, disabled) => `
1833
+ <label class="choice">
1834
+ <input
1835
+ type="checkbox"
1836
+ data-publish="${key}"
1837
+ ${on[key] ? 'checked' : ''}
1838
+ ${disabled ? 'disabled' : ''}
1839
+ />
1840
+ <span>
1841
+ <b>${label}</b>
1842
+ <span class="sub">${hint}</span>
1843
+ </span>
1844
+ </label>`;
1845
+
1805
1846
  pane.innerHTML =
1847
+ `<div class="publish">
1848
+ ${toggle(
1849
+ 'serveArchive',
1850
+ 'Serve this archive over HTTP',
1851
+ `<code>${escapeHtml(archiveUrl)}</code> — the file itself,
1852
+ by byte range, which is what any PMTiles reader wants.
1853
+ Off means this node answers 403 for it.`,
1854
+ false,
1855
+ )}
1856
+ ${toggle(
1857
+ 'selfWebSeed',
1858
+ 'Publish this node as a web seed for it',
1859
+ `Writes that URL into the torrent, so every peer holding it
1860
+ fetches from here. Turning it off takes the URL back out,
1861
+ but peers already holding the torrent keep trying it for a
1862
+ while.`,
1863
+ !on.serveArchive,
1864
+ )}
1865
+ ${toggle(
1866
+ 'publicDownload',
1867
+ 'Offer it as a download on the public page',
1868
+ `A link on the catalogue page anyone can reach. Separate
1869
+ from serving it: the endpoint can exist for a style or a
1870
+ peer without being advertised to every visitor.`,
1871
+ !on.serveArchive,
1872
+ )}
1873
+ </div>` +
1874
+ `<h2>Web seeds</h2>` +
1806
1875
  table(
1807
1876
  ['URL'],
1808
1877
  seeds.map((url) => [escapeHtml(url)]),
@@ -1830,6 +1899,50 @@
1830
1899
  // Attached here rather than in the panel, because this pane is
1831
1900
  // rebuilt whenever it is shown and the handler has to survive that.
1832
1901
  // Safe: sources is not one of the panes the refresh redraws.
1902
+ for (const box of pane.querySelectorAll('[data-publish]')) {
1903
+ box.onchange = async () => {
1904
+ const key = box.dataset.publish;
1905
+ const want = box.checked;
1906
+ // Asked before the request, not after: this is the one that
1907
+ // changes what strangers can pull off the disk, and a checkbox
1908
+ // is a very quiet way to hand out 700 GiB.
1909
+ if (
1910
+ key === 'serveArchive' &&
1911
+ want &&
1912
+ !window.confirm(
1913
+ 'Serve this archive over HTTP.\n\n' +
1914
+ 'Anyone who knows the infohash can then download the ' +
1915
+ 'whole file — ' +
1916
+ (bytes(entry.size) || 'the entire archive') +
1917
+ ' — from this node.',
1918
+ )
1919
+ ) {
1920
+ box.checked = false;
1921
+ return;
1922
+ }
1923
+ box.disabled = true;
1924
+ try {
1925
+ const result = await api(
1926
+ `/api/torrents/${infoHash}/publish`,
1927
+ { method: 'POST', body: { [key]: want } },
1928
+ );
1929
+ // The withdrawal is worth saying out loud. The others are
1930
+ // visible in the checkbox that was just clicked.
1931
+ if (result.withdrewWebSeed) {
1932
+ toast(
1933
+ 'web seed withdrawn; peers holding the torrent will ' +
1934
+ 'keep trying it until they refresh it',
1935
+ );
1936
+ }
1937
+ renderDetail(infoHash);
1938
+ } catch (error) {
1939
+ toast(error.message);
1940
+ box.checked = !want;
1941
+ box.disabled = false;
1942
+ }
1943
+ };
1944
+ }
1945
+
1833
1946
  const add = async () => {
1834
1947
  const url = pane.querySelector('#seed-url').value.trim();
1835
1948
  if (!url) return;
@@ -334,12 +334,27 @@
334
334
  }
335
335
  add(`${root}/archive.torrent`, '.torrent');
336
336
  if (archive.magnet) add(archive.magnet, 'magnet');
337
+ // Only where the node offers this archive as a download. The
338
+ // catalogue names it rather than the page deciding, because whether
339
+ // a 700 GiB file is advertised to every visitor is the operator's
340
+ // call and not something a page can infer from the file existing.
341
+ if (archive.archive) {
342
+ const download = el('a', null, 'download');
343
+ download.href = archive.archive;
344
+ // Named after the archive rather than after the route, which for
345
+ // every archive on the node is the same word.
346
+ download.download = archive.name ?? '';
347
+ links.append(download);
348
+ }
337
349
  card.append(links);
338
350
 
339
351
  const urls = el('div', 'urls');
340
352
  if (servable) {
341
353
  urls.append(urlRow('TileJSON', new URL(`${root}/tiles.json`, location.href).href));
342
354
  }
355
+ if (archive.archive) {
356
+ urls.append(urlRow('archive', archive.archive));
357
+ }
343
358
  for (const seed of archive.webSeeds ?? []) {
344
359
  urls.append(urlRow('web seed', seed));
345
360
  }