pmtiles-swarm 0.22.0 → 0.23.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 +13 -0
- package/package.json +1 -1
- package/src/api.js +21 -1
- package/src/mutable.js +16 -0
- package/src/publisher.js +8 -1
- package/src/tilejson.js +4 -1
- package/src/web/preview.html +12 -3
- package/src/web/public.html +118 -2
package/CHANGELOG.md
CHANGED
|
@@ -58,6 +58,13 @@
|
|
|
58
58
|
`/api/categories`, filtered by the same `feedCategories` rule, so it can show nothing that was
|
|
59
59
|
not already published — and it is not the console, which stays on the admin port.
|
|
60
60
|
`publicIndex: false` turns it off, withdrawing the three paths it needs with it.
|
|
61
|
+
- **A category can be previewed, and the preview is the category's own URL.** `/latest/<category>/preview`
|
|
62
|
+
reads the TileJSON beside it, so it renders whatever build is current rather than pinning to the
|
|
63
|
+
one that was newest when the link was made — the same URL a style holds, demonstrating itself.
|
|
64
|
+
The preview page now derives its source from wherever it is served instead of assembling one from
|
|
65
|
+
an infohash, so one page serves both forms.
|
|
66
|
+
- **The public page gained a filter and a sort.** Search by name, infohash or category; order by name,
|
|
67
|
+
newest or largest. Both re-render from what was already loaded rather than asking the node again.
|
|
61
68
|
- **`GET /latest/` lists the categories, without a credential.** Everything else under
|
|
62
69
|
`/latest/` is public — the TileJSON, the torrent, the magnet, the per-category feed — so the
|
|
63
70
|
index of what it offers belongs beside them rather than behind the console's door. The public
|
|
@@ -77,6 +84,12 @@
|
|
|
77
84
|
never checked.
|
|
78
85
|
|
|
79
86
|
### 🐞 Bug fixes
|
|
87
|
+
- **A mutable magnet had nowhere to announce.** `mutableMagnet()` was never passed trackers, so the
|
|
88
|
+
BEP 46 form carried `xt`, `xs`, `dn`, `s` and `ws` and no `tr=` at all. The infohash added in
|
|
89
|
+
0.21.0 was therefore unusable from a browser, which has neither DHT nor peer exchange to fall
|
|
90
|
+
back on and so had nothing to ask — leaving the web seed, which is just HTTP, as the only source.
|
|
91
|
+
The archive's own trackers are now lifted across in all three places one is built: the TileJSON's
|
|
92
|
+
torrent block, the `styleUrl` fragment, and the publisher's own magnet.
|
|
80
93
|
- **The public front page could not read its own catalogue.** `/api/catalog` is on the list of
|
|
81
94
|
paths that belong on a public listener, but that is a separate gate from the credential check,
|
|
82
95
|
which guards everything under `/api/` except login and session — so on any node with
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmtiles-swarm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.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
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
isPublicSurface,
|
|
15
15
|
} from './auth.js';
|
|
16
16
|
import { normalizeCategories } from './catalog.js';
|
|
17
|
-
import { mutableMagnet } from './mutable.js';
|
|
17
|
+
import { mutableMagnet, trackersFromMagnet } from './mutable.js';
|
|
18
18
|
import { guessKind } from './library.js';
|
|
19
19
|
import { QBittorrentEngine } from './engines/qbittorrent.js';
|
|
20
20
|
import { RESTART_REQUIRED, redactConfig, saveConfig } from './config.js';
|
|
@@ -80,6 +80,10 @@ function styleUrlFor(category, newest, base) {
|
|
|
80
80
|
// of this URL — a client that cannot reach the tiles.json in front of
|
|
81
81
|
// it, or cannot resolve a public key, still has something to join.
|
|
82
82
|
infoHash: newest.infoHash,
|
|
83
|
+
// Somewhere to announce it, lifted from the archive's own magnet. A
|
|
84
|
+
// fragment carrying an infohash and no tracker gives a browser
|
|
85
|
+
// something to join and nobody to ask for it.
|
|
86
|
+
trackers: trackersFromMagnet(newest.magnet),
|
|
83
87
|
salt: newest.mutable.salt ?? category,
|
|
84
88
|
// The category, since that is what this magnet resolves to.
|
|
85
89
|
name: newest.mutable.salt ?? category,
|
|
@@ -1802,6 +1806,11 @@ export function createApp({
|
|
|
1802
1806
|
// newest build's own magnet, which pins that build but still
|
|
1803
1807
|
// beats a blank map when the fallback is needed at all.
|
|
1804
1808
|
styleUrl: servable ? styleUrlFor(category, newest, base) : null,
|
|
1809
|
+
// Points at the category, not at a build. The page reads the
|
|
1810
|
+
// TileJSON beside it, so it renders whatever is current — which
|
|
1811
|
+
// makes it the same URL a style holds, demonstrating itself rather
|
|
1812
|
+
// than pinning to today's infohash.
|
|
1813
|
+
preview: servable ? `${base}/latest/${category}/preview` : null,
|
|
1805
1814
|
torrent: `${base}/latest/${category}/archive.torrent`,
|
|
1806
1815
|
magnet: `${base}/latest/${category}/magnet`,
|
|
1807
1816
|
feed: `${base}/feed/${category}.xml`,
|
|
@@ -1973,6 +1982,17 @@ export function createApp({
|
|
|
1973
1982
|
// shareable and says what it shows. The page reads the TileJSON next to it —
|
|
1974
1983
|
// which is a complete, valid TileJSON already, so nothing here has to invent
|
|
1975
1984
|
// a source description.
|
|
1985
|
+
// The same page for a category, which is the more useful one to hand
|
|
1986
|
+
// somebody: it reads /latest/<category>/tiles.json, so it renders whatever
|
|
1987
|
+
// build is current rather than pinning to the one that happened to be newest
|
|
1988
|
+
// when the link was made. That is exactly what a style points at, so this is
|
|
1989
|
+
// the URL demonstrating itself.
|
|
1990
|
+
//
|
|
1991
|
+
// The page works out which by its own path, so there is nothing to pass.
|
|
1992
|
+
app.get('/latest/:category/preview', (_req, res) => {
|
|
1993
|
+
res.sendFile(path.join(here, 'web', 'preview.html'));
|
|
1994
|
+
});
|
|
1995
|
+
|
|
1976
1996
|
app.get('/archives/:infoHash/preview', (_req, res) => {
|
|
1977
1997
|
res.sendFile(path.join(here, 'web', 'preview.html'));
|
|
1978
1998
|
});
|
package/src/mutable.js
CHANGED
|
@@ -90,6 +90,22 @@ function rawPublicKey(publicKey) {
|
|
|
90
90
|
* @param {string} [options.salt] - Salt, when one key publishes several archives.
|
|
91
91
|
* @returns {string} - A BEP 46 magnet URI.
|
|
92
92
|
*/
|
|
93
|
+
/**
|
|
94
|
+
* The trackers a magnet announces to.
|
|
95
|
+
*
|
|
96
|
+
* A BEP 46 magnet is assembled rather than parsed from a torrent, so it has no
|
|
97
|
+
* announce list of its own — but the archive it names does, in the plain magnet
|
|
98
|
+
* beside it. Lifting them across is what makes the mutable form joinable: an
|
|
99
|
+
* infohash with nowhere to announce is an infohash a browser cannot act on,
|
|
100
|
+
* having neither DHT nor peer exchange to fall back on.
|
|
101
|
+
* @param {string} [magnet] - A magnet URI to read `tr=` out of.
|
|
102
|
+
* @returns {string[]} - The announce URLs, in the order they appeared.
|
|
103
|
+
*/
|
|
104
|
+
export function trackersFromMagnet(magnet) {
|
|
105
|
+
if (typeof magnet !== 'string' || !magnet.startsWith('magnet:?')) return [];
|
|
106
|
+
return new URLSearchParams(magnet.slice('magnet:?'.length)).getAll('tr');
|
|
107
|
+
}
|
|
108
|
+
|
|
93
109
|
export function mutableMagnet(publicKey, options = {}) {
|
|
94
110
|
// Buffer.from(string) would read hex as UTF-8 and produce a 64-byte key, so
|
|
95
111
|
// the two forms have to be told apart rather than coerced.
|
package/src/publisher.js
CHANGED
|
@@ -17,7 +17,11 @@
|
|
|
17
17
|
* docs/internals.md — "Why a third DHT".
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
mutableMagnet,
|
|
22
|
+
publishInfoHash,
|
|
23
|
+
trackersFromMagnet,
|
|
24
|
+
} from './mutable.js';
|
|
21
25
|
|
|
22
26
|
/** Republish well inside the ~2h a DHT keeps an item. */
|
|
23
27
|
const DEFAULT_INTERVAL_MS = 30 * 60 * 1000;
|
|
@@ -197,6 +201,9 @@ export class MutablePublisher {
|
|
|
197
201
|
// The current build, for a client that cannot resolve the key. Absent
|
|
198
202
|
// when no entry was given, which leaves the series-only form.
|
|
199
203
|
infoHash: entry?.infoHash,
|
|
204
|
+
// Somewhere to announce it, lifted from the archive's own magnet. An
|
|
205
|
+
// infohash with no tracker beside it is one a browser cannot act on.
|
|
206
|
+
trackers: trackersFromMagnet(entry?.magnet),
|
|
200
207
|
salt: category,
|
|
201
208
|
// The category rather than the build. This magnet resolves to whichever
|
|
202
209
|
// archive is current, so naming one of them dates the string the moment
|
package/src/tilejson.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mutableMagnet } from './mutable.js';
|
|
1
|
+
import { mutableMagnet, trackersFromMagnet } from './mutable.js';
|
|
2
2
|
import { TileStore } from './tiles.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -106,6 +106,9 @@ function buildTorrentBlock(entry, root) {
|
|
|
106
106
|
// can join from this string alone rather than having to come back for
|
|
107
107
|
// an infohash.
|
|
108
108
|
infoHash: entry.infoHash,
|
|
109
|
+
// And somewhere to announce it. Without these the infohash above is
|
|
110
|
+
// unusable from a page: no DHT, no peer exchange, nothing to ask.
|
|
111
|
+
trackers: trackersFromMagnet(entry.magnet),
|
|
109
112
|
salt: entry.mutable.salt,
|
|
110
113
|
// The category, not this build: the record resolves to whichever
|
|
111
114
|
// archive is current, and `dn` is a label the metadata replaces.
|
package/src/web/preview.html
CHANGED
|
@@ -79,8 +79,17 @@
|
|
|
79
79
|
import * as maplibregl from '/vendor/maplibre-gl/maplibre-gl.mjs';
|
|
80
80
|
import MaplibreInspect from '/vendor/maplibre-gl-inspect/maplibre-gl-inspect.mjs';
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
// Derived from wherever this page is served rather than assembled from
|
|
83
|
+
// an infohash, so one page previews both an archive and a category:
|
|
84
|
+
// /archives/<infohash>/preview -> /archives/<infohash>/tiles.json
|
|
85
|
+
// /latest/<category>/preview -> /latest/<category>/tiles.json
|
|
86
|
+
// The second is the one worth having, because it is what a style should
|
|
87
|
+
// actually point at — it follows the newest build instead of pinning to
|
|
88
|
+
// the one that happened to be current when the link was made.
|
|
89
|
+
const tileJsonUrl = location.pathname.replace(
|
|
90
|
+
/\/preview\/?$/,
|
|
91
|
+
'/tiles.json',
|
|
92
|
+
);
|
|
84
93
|
const $ = (id) => document.getElementById(id);
|
|
85
94
|
|
|
86
95
|
const fail = (message) => {
|
|
@@ -104,7 +113,7 @@
|
|
|
104
113
|
const vector =
|
|
105
114
|
vectorLayers.length > 0 || /\.(pbf|mvt)(\?|$)/.test(tilejson.tiles?.[0] ?? '');
|
|
106
115
|
|
|
107
|
-
$('name').textContent = tilejson.name ??
|
|
116
|
+
$('name').textContent = tilejson.name ?? tileJsonUrl;
|
|
108
117
|
$('summary').textContent =
|
|
109
118
|
`${vector ? 'vector' : 'raster'} · z${tilejson.minzoom ?? 0}–${tilejson.maxzoom ?? 14}` +
|
|
110
119
|
(vector
|
package/src/web/public.html
CHANGED
|
@@ -159,6 +159,42 @@
|
|
|
159
159
|
display: block;
|
|
160
160
|
margin-bottom: 1.5rem;
|
|
161
161
|
}
|
|
162
|
+
.toolbar {
|
|
163
|
+
display: flex;
|
|
164
|
+
flex-wrap: wrap;
|
|
165
|
+
gap: 0.6rem 0.9rem;
|
|
166
|
+
align-items: center;
|
|
167
|
+
margin-bottom: 1.5rem;
|
|
168
|
+
}
|
|
169
|
+
.toolbar input[type='search'] {
|
|
170
|
+
flex: 1 1 18rem;
|
|
171
|
+
min-width: 0;
|
|
172
|
+
padding: 0.45rem 0.6rem;
|
|
173
|
+
border: 1px solid var(--line);
|
|
174
|
+
border-radius: 6px;
|
|
175
|
+
background: var(--card);
|
|
176
|
+
color: inherit;
|
|
177
|
+
font: inherit;
|
|
178
|
+
}
|
|
179
|
+
.toolbar label {
|
|
180
|
+
display: inline-flex;
|
|
181
|
+
align-items: center;
|
|
182
|
+
gap: 0.4rem;
|
|
183
|
+
color: var(--muted);
|
|
184
|
+
font-size: 0.9rem;
|
|
185
|
+
}
|
|
186
|
+
.toolbar select {
|
|
187
|
+
padding: 0.35rem 0.4rem;
|
|
188
|
+
border: 1px solid var(--line);
|
|
189
|
+
border-radius: 6px;
|
|
190
|
+
background: var(--card);
|
|
191
|
+
color: inherit;
|
|
192
|
+
font: inherit;
|
|
193
|
+
}
|
|
194
|
+
.count {
|
|
195
|
+
color: var(--muted);
|
|
196
|
+
font-size: 0.85rem;
|
|
197
|
+
}
|
|
162
198
|
</style>
|
|
163
199
|
</head>
|
|
164
200
|
<body>
|
|
@@ -176,6 +212,25 @@
|
|
|
176
212
|
</p>
|
|
177
213
|
</noscript>
|
|
178
214
|
|
|
215
|
+
<div class="toolbar" id="toolbar" hidden>
|
|
216
|
+
<input
|
|
217
|
+
type="search"
|
|
218
|
+
id="filter"
|
|
219
|
+
placeholder="Filter by name or category…"
|
|
220
|
+
autocomplete="off"
|
|
221
|
+
spellcheck="false"
|
|
222
|
+
/>
|
|
223
|
+
<label>
|
|
224
|
+
Sort
|
|
225
|
+
<select id="sort">
|
|
226
|
+
<option value="name">name</option>
|
|
227
|
+
<option value="newest">newest first</option>
|
|
228
|
+
<option value="largest">largest first</option>
|
|
229
|
+
</select>
|
|
230
|
+
</label>
|
|
231
|
+
<span class="count" id="count"></span>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
179
234
|
<div id="categories"></div>
|
|
180
235
|
<div id="archives"><p class="empty">Loading…</p></div>
|
|
181
236
|
|
|
@@ -337,6 +392,9 @@
|
|
|
337
392
|
};
|
|
338
393
|
const ends = entry.endpoints;
|
|
339
394
|
if (ends.tileJson) add(ends.tileJson, 'TileJSON');
|
|
395
|
+
// The newest build's preview, which is what "show me this
|
|
396
|
+
// category" means.
|
|
397
|
+
if (ends.preview) add(ends.preview, 'preview');
|
|
340
398
|
if (ends.torrent) add(ends.torrent, '.torrent');
|
|
341
399
|
if (ends.magnet) add(ends.magnet, 'magnet');
|
|
342
400
|
if (ends.feed) add(ends.feed, 'RSS');
|
|
@@ -407,6 +465,58 @@
|
|
|
407
465
|
});
|
|
408
466
|
};
|
|
409
467
|
|
|
468
|
+
// Held so the filter and the sort re-render from memory rather than
|
|
469
|
+
// asking the node again on every keystroke.
|
|
470
|
+
let loadedArchives = [];
|
|
471
|
+
let loadedCategories = [];
|
|
472
|
+
|
|
473
|
+
const matches = (text, needle) =>
|
|
474
|
+
String(text ?? '')
|
|
475
|
+
.toLowerCase()
|
|
476
|
+
.includes(needle);
|
|
477
|
+
|
|
478
|
+
const sorters = {
|
|
479
|
+
name: (a, b) => String(a.name ?? '').localeCompare(String(b.name ?? '')),
|
|
480
|
+
newest: (a, b) =>
|
|
481
|
+
new Date(b.createdAt ?? 0) - new Date(a.createdAt ?? 0),
|
|
482
|
+
largest: (a, b) => (b.size ?? 0) - (a.size ?? 0),
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
// Re-renders both lists against the current filter and sort.
|
|
486
|
+
const apply = () => {
|
|
487
|
+
const needle = (document.getElementById('filter').value ?? '')
|
|
488
|
+
.trim()
|
|
489
|
+
.toLowerCase();
|
|
490
|
+
const order = document.getElementById('sort').value;
|
|
491
|
+
|
|
492
|
+
const archives = loadedArchives
|
|
493
|
+
.filter(
|
|
494
|
+
(archive) =>
|
|
495
|
+
needle === '' ||
|
|
496
|
+
matches(archive.name, needle) ||
|
|
497
|
+
matches(archive.infoHash, needle) ||
|
|
498
|
+
(archive.categories ?? []).some((c) => matches(c, needle)),
|
|
499
|
+
)
|
|
500
|
+
.sort(sorters[order] ?? sorters.name);
|
|
501
|
+
|
|
502
|
+
const categories = loadedCategories.filter(
|
|
503
|
+
(entry) =>
|
|
504
|
+
needle === '' ||
|
|
505
|
+
matches(entry.category, needle) ||
|
|
506
|
+
matches(entry.newest?.name, needle),
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
renderCategories(categories);
|
|
510
|
+
render(archives);
|
|
511
|
+
|
|
512
|
+
const count = document.getElementById('count');
|
|
513
|
+
count.textContent =
|
|
514
|
+
needle === ''
|
|
515
|
+
? `${loadedArchives.length} archives, ${loadedCategories.length} categories`
|
|
516
|
+
: `${archives.length} of ${loadedArchives.length} archives, ` +
|
|
517
|
+
`${categories.length} of ${loadedCategories.length} categories`;
|
|
518
|
+
};
|
|
519
|
+
|
|
410
520
|
const load = async () => {
|
|
411
521
|
let archives = null;
|
|
412
522
|
|
|
@@ -440,7 +550,9 @@
|
|
|
440
550
|
}
|
|
441
551
|
}
|
|
442
552
|
|
|
443
|
-
|
|
553
|
+
loadedArchives = archives.filter((archive) => archive.infoHash);
|
|
554
|
+
document.getElementById('toolbar').hidden = false;
|
|
555
|
+
apply();
|
|
444
556
|
|
|
445
557
|
// /latest/ rather than /api/categories: the same list, without a
|
|
446
558
|
// credential. This is the part most visitors actually want -- an
|
|
@@ -451,13 +563,17 @@
|
|
|
451
563
|
const response = await fetch('/latest/');
|
|
452
564
|
if (response.ok) {
|
|
453
565
|
const body = await response.json();
|
|
454
|
-
|
|
566
|
+
loadedCategories = body.categories ?? [];
|
|
567
|
+
apply();
|
|
455
568
|
}
|
|
456
569
|
} catch {
|
|
457
570
|
// Nothing to say. The archives above are the substance.
|
|
458
571
|
}
|
|
459
572
|
};
|
|
460
573
|
|
|
574
|
+
document.getElementById('filter').addEventListener('input', apply);
|
|
575
|
+
document.getElementById('sort').addEventListener('change', apply);
|
|
576
|
+
|
|
461
577
|
load();
|
|
462
578
|
</script>
|
|
463
579
|
</body>
|