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 +32 -0
- package/README.md +4 -2
- package/docs/configuration.md +86 -9
- package/docs/serving-tiles.md +6 -0
- package/package.json +1 -1
- package/src/api.js +95 -2
- package/src/catalog.js +41 -0
- package/src/config.js +41 -1
- package/src/library.js +203 -32
- package/src/sources.js +6 -0
- package/src/watch.js +4 -0
- package/src/web/index.html +113 -0
- package/src/web/public.html +15 -0
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
|
|
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
|
package/docs/configuration.md
CHANGED
|
@@ -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`
|
|
426
|
-
| `index`
|
|
427
|
-
| `newest`
|
|
428
|
-
| `at`
|
|
429
|
-
| `everyHours`
|
|
430
|
-
| `md5`
|
|
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.
|
package/docs/serving-tiles.md
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
2680
|
-
*
|
|
2681
|
-
*
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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.
|
package/src/web/index.html
CHANGED
|
@@ -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;
|
package/src/web/public.html
CHANGED
|
@@ -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
|
}
|