pmtiles-swarm 0.20.0 → 0.22.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 +21 -0
- package/docs/serving-tiles.md +21 -0
- package/package.json +1 -1
- package/src/api.js +89 -48
- package/src/feed.js +20 -0
- package/src/watch.js +30 -0
- package/src/web/public.html +163 -31
package/CHANGELOG.md
CHANGED
|
@@ -58,12 +58,33 @@
|
|
|
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
|
+
- **`GET /latest/` lists the categories, without a credential.** Everything else under
|
|
62
|
+
`/latest/` is public — the TileJSON, the torrent, the magnet, the per-category feed — so the
|
|
63
|
+
index of what it offers belongs beside them rather than behind the console's door. The public
|
|
64
|
+
front page now leads with categories rather than only listing archives, which is the more
|
|
65
|
+
useful handle for a visitor: an archive URL names one build and goes stale on the next, while a
|
|
66
|
+
category names whichever is current. Same builder as `/api/categories`, so the two cannot drift
|
|
67
|
+
apart. The page's footer and its noscript block no longer link `/api/` paths that answer 401 to
|
|
68
|
+
exactly the audience they are shown to.
|
|
69
|
+
- **The feed carries the BEP 46 identity.** `<pmtiles:mutable>` holds the magnet naming an
|
|
70
|
+
archive's publishing key, so a consumer following a category across rebuilds can read the
|
|
71
|
+
public key straight out of the feed instead of fetching a TileJSON to find it — the key rides
|
|
72
|
+
inside the string as `xs=urn:btpk:`. Absent for archives that have no identity, since an empty
|
|
73
|
+
element would be a claim.
|
|
61
74
|
- **Lint and format tooling.** `npm run lint`, `lint:fix`, `format`, `format:check`, `tidy` and
|
|
62
75
|
`check`, wired into CI. There was no linter before, though the source carried
|
|
63
76
|
`eslint-disable` comments for one — so those suppressed nothing and the rules they named were
|
|
64
77
|
never checked.
|
|
65
78
|
|
|
66
79
|
### 🐞 Bug fixes
|
|
80
|
+
- **The public front page could not read its own catalogue.** `/api/catalog` is on the list of
|
|
81
|
+
paths that belong on a public listener, but that is a separate gate from the credential check,
|
|
82
|
+
which guards everything under `/api/` except login and session — so on any node with
|
|
83
|
+
authentication configured the page answered "catalog said 401" and listed nothing. It now
|
|
84
|
+
prefers the catalogue, which carries web seeds and the sparse flag, and falls back to
|
|
85
|
+
`/feed.xml`, which needs no credential and says the same things: name, infohash, magnet, size,
|
|
86
|
+
categories, format and zoom range. Deliberately not fixed by making the catalogue
|
|
87
|
+
world-readable, which would undo a decision the operator made on purpose.
|
|
67
88
|
- **WebTorrent's `pieces()` was defined twice.** The later definition wins, so the first had
|
|
68
89
|
never run — which is why it still called an undefined `countHeld` and no test noticed.
|
|
69
90
|
- **A stuck HTTP connection could keep a shutdown waiting.** `closeServer` armed its force-close
|
package/docs/serving-tiles.md
CHANGED
|
@@ -41,6 +41,27 @@ swarm on every attempt, doing work that could never succeed.
|
|
|
41
41
|
|
|
42
42
|
See [internals.md](internals.md#serving-an-mbtiles-archive).
|
|
43
43
|
|
|
44
|
+
## The category index
|
|
45
|
+
|
|
46
|
+
`GET /latest/` lists every category this node publishes, with the endpoints
|
|
47
|
+
that resolve to each one's newest build — the TileJSON, the `.torrent`, the
|
|
48
|
+
magnet, the per-category feed, and the style URL with the magnet in its
|
|
49
|
+
fragment.
|
|
50
|
+
|
|
51
|
+
Public, and deliberately so. Everything else under `/latest/` is — the
|
|
52
|
+
TileJSON, the torrent, the magnet, the feed — so the index of what `/latest/`
|
|
53
|
+
offers belongs beside them rather than behind the console's door.
|
|
54
|
+
`/api/categories` returns the identical list from the identical builder, and is
|
|
55
|
+
guarded only because everything under `/api/` is.
|
|
56
|
+
|
|
57
|
+
A category is also the handle worth publishing. An archive URL names one build
|
|
58
|
+
and goes stale on the next; a category names whichever is current, so a style
|
|
59
|
+
pointing at one keeps working across rebuilds.
|
|
60
|
+
|
|
61
|
+
Filtered by `feedCategories` and by whatever token was presented, the same as
|
|
62
|
+
the catalogue and the feeds — so it can show nothing this node was not already
|
|
63
|
+
publishing.
|
|
64
|
+
|
|
44
65
|
## Missing tiles
|
|
45
66
|
|
|
46
67
|
A tile the archive does not hold answers **404 for raster and 204 for vector**,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmtiles-swarm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.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
|
@@ -1744,9 +1744,19 @@ export function createApp({
|
|
|
1744
1744
|
// what makes a tile immutable, but leaves nothing for a style to point at
|
|
1745
1745
|
// that survives a rebuild — and until now the endpoints that give you one
|
|
1746
1746
|
// were documented rather than discoverable.
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1747
|
+
/**
|
|
1748
|
+
* Every category, with the endpoints that resolve to its newest build.
|
|
1749
|
+
*
|
|
1750
|
+
* Shared by the guarded `/api/categories` and the public `/latest/`, which
|
|
1751
|
+
* want exactly the same answer — one is the console asking and the other is
|
|
1752
|
+
* a stranger, and neither is told anything the other is not. Filtering runs
|
|
1753
|
+
* through `publishesEntry`, so `feedCategories` and whatever token was
|
|
1754
|
+
* presented decide what appears, the same as everywhere else.
|
|
1755
|
+
* @param {import('express').Request} req - The request, for its credential and base URL.
|
|
1756
|
+
* @returns {object[]} - One row per category, sorted by name.
|
|
1757
|
+
*/
|
|
1758
|
+
const describeCategories = (req) => {
|
|
1759
|
+
{
|
|
1750
1760
|
const base = baseUrl(req);
|
|
1751
1761
|
const visible = catalog
|
|
1752
1762
|
.list()
|
|
@@ -1759,51 +1769,82 @@ export function createApp({
|
|
|
1759
1769
|
}
|
|
1760
1770
|
}
|
|
1761
1771
|
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
servable,
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1772
|
+
return [...counts.keys()].sort().map((category) => {
|
|
1773
|
+
const newest = newestIn(category, req);
|
|
1774
|
+
// Only PMTiles has tiles to serve, so a category whose newest build
|
|
1775
|
+
// is an MBTiles archive gets a feed and a torrent but no tile
|
|
1776
|
+
// endpoint — the same rule as an individual archive.
|
|
1777
|
+
const servable =
|
|
1778
|
+
Boolean(newest?.pmtiles) && (newest.kind ?? 'pmtiles') === 'pmtiles';
|
|
1779
|
+
|
|
1780
|
+
return {
|
|
1781
|
+
category,
|
|
1782
|
+
archives: counts.get(category),
|
|
1783
|
+
newest: newest && {
|
|
1784
|
+
infoHash: newest.infoHash,
|
|
1785
|
+
name: newest.name,
|
|
1786
|
+
size: newest.size,
|
|
1787
|
+
createdAt: newest.createdAt,
|
|
1788
|
+
kind: newest.kind ?? 'pmtiles',
|
|
1789
|
+
},
|
|
1790
|
+
servable,
|
|
1791
|
+
endpoints: {
|
|
1792
|
+
tileJson: servable ? `${base}/latest/${category}/tiles.json` : null,
|
|
1793
|
+
// The same URL with a magnet in the fragment, which is what a
|
|
1794
|
+
// style should carry. A fragment is never sent in a request, so
|
|
1795
|
+
// an ordinary client fetches the TileJSON and ignores it, while
|
|
1796
|
+
// a swarm-aware one has the magnet before it makes any call --
|
|
1797
|
+
// and can therefore still start when this server cannot answer.
|
|
1798
|
+
//
|
|
1799
|
+
// The mutable magnet where there is one, because a category is
|
|
1800
|
+
// precisely where an infohash goes stale: it names the category
|
|
1801
|
+
// and resolves the current build over the DHT. Otherwise the
|
|
1802
|
+
// newest build's own magnet, which pins that build but still
|
|
1803
|
+
// beats a blank map when the fallback is needed at all.
|
|
1804
|
+
styleUrl: servable ? styleUrlFor(category, newest, base) : null,
|
|
1805
|
+
torrent: `${base}/latest/${category}/archive.torrent`,
|
|
1806
|
+
magnet: `${base}/latest/${category}/magnet`,
|
|
1807
|
+
feed: `${base}/feed/${category}.xml`,
|
|
1808
|
+
latestFeed: `${base}/latest/${category}.xml`,
|
|
1809
|
+
},
|
|
1810
|
+
};
|
|
1811
|
+
});
|
|
1812
|
+
}
|
|
1813
|
+
};
|
|
1814
|
+
|
|
1815
|
+
// The console's view. Identical to the public one below, and guarded only
|
|
1816
|
+
// because everything under /api/ is.
|
|
1817
|
+
app.get(
|
|
1818
|
+
'/api/categories',
|
|
1819
|
+
route(async (req, res) => {
|
|
1820
|
+
res.json(describeCategories(req));
|
|
1821
|
+
}),
|
|
1822
|
+
);
|
|
1823
|
+
|
|
1824
|
+
// The same list, without a credential.
|
|
1825
|
+
//
|
|
1826
|
+
// Everything under /latest/ is public already — the TileJSON, the torrent,
|
|
1827
|
+
// the magnet, the per-category feed — so the index of what /latest/ offers
|
|
1828
|
+
// belongs here rather than behind the console's door. A category is also the
|
|
1829
|
+
// handle a person actually wants: an infohash names one build, and this
|
|
1830
|
+
// names whichever is current, so a style pointing at it keeps working across
|
|
1831
|
+
// rebuilds.
|
|
1832
|
+
//
|
|
1833
|
+
// Not a second implementation. It is the same builder, filtered by the same
|
|
1834
|
+
// rule, so the two cannot drift into disagreeing about what is published.
|
|
1835
|
+
app.get(
|
|
1836
|
+
'/latest/',
|
|
1837
|
+
route(async (req, res) => {
|
|
1838
|
+
res.setHeader('access-control-allow-origin', '*');
|
|
1839
|
+
// Short, because the answer changes when a build lands.
|
|
1840
|
+
res.setHeader('cache-control', 'public, max-age=60');
|
|
1841
|
+
res.json({
|
|
1842
|
+
// Named so a consumer can refuse a document it does not understand,
|
|
1843
|
+
// the same as the catalogue does.
|
|
1844
|
+
format: 'pmtiles-swarm-categories/1',
|
|
1845
|
+
generatedAt: new Date().toISOString(),
|
|
1846
|
+
categories: describeCategories(req),
|
|
1847
|
+
});
|
|
1807
1848
|
}),
|
|
1808
1849
|
);
|
|
1809
1850
|
|
package/src/feed.js
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* subscriber restores it on completion.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { mutableMagnet } from './mutable.js';
|
|
21
|
+
|
|
20
22
|
const PMTILES_NS = 'https://github.com/TechIdiots-LLC/pmtiles-swarm/ns/1.0';
|
|
21
23
|
|
|
22
24
|
/**
|
|
@@ -118,6 +120,18 @@ ${(entry.categories ?? (entry.category ? [entry.category] : []))
|
|
|
118
120
|
<enclosure url="${xml(torrentUrl)}" length="${xml(entry.size)}" type="application/x-bittorrent"/>
|
|
119
121
|
<pmtiles:infohash>${xml(entry.infoHash)}</pmtiles:infohash>
|
|
120
122
|
<pmtiles:magnet>${xml(entry.magnet)}</pmtiles:magnet>
|
|
123
|
+
${
|
|
124
|
+
entry.mutable?.publicKey
|
|
125
|
+
? ` <pmtiles:mutable>${xml(
|
|
126
|
+
mutableMagnet(entry.mutable.publicKey, {
|
|
127
|
+
infoHash: entry.infoHash,
|
|
128
|
+
salt: entry.mutable.salt,
|
|
129
|
+
name: entry.mutable.salt ?? entry.name,
|
|
130
|
+
webSeeds: entry.webSeeds,
|
|
131
|
+
}),
|
|
132
|
+
)}</pmtiles:mutable>`
|
|
133
|
+
: ''
|
|
134
|
+
}
|
|
121
135
|
${entry.md5 ? ` <pmtiles:md5>${xml(entry.md5)}</pmtiles:md5>` : ''}
|
|
122
136
|
${entry.originMtime ? ` <pmtiles:mtime>${xml(entry.originMtime)}</pmtiles:mtime>` : ''}
|
|
123
137
|
${mapFields}
|
|
@@ -150,6 +164,7 @@ export function formatBytes(bytes) {
|
|
|
150
164
|
* @property {string} [torrentUrl] - URL of a .torrent enclosure.
|
|
151
165
|
* @property {string} [category] - Item category.
|
|
152
166
|
* @property {string} [mtime] - The archive's mtime on the node that built it.
|
|
167
|
+
* @property {string} [mutableMagnet] - BEP 46 magnet, when the publisher has an identity for it.
|
|
153
168
|
*/
|
|
154
169
|
|
|
155
170
|
/**
|
|
@@ -196,6 +211,11 @@ export function parseFeed(body) {
|
|
|
196
211
|
items.push({
|
|
197
212
|
title: title ?? 'untitled',
|
|
198
213
|
infoHash: tag(block, 'pmtiles:infohash')?.toLowerCase(),
|
|
214
|
+
// The BEP 46 magnet, when the publisher has an identity for this
|
|
215
|
+
// archive. Carried so a consumer can follow the series across rebuilds,
|
|
216
|
+
// and so it can read the public key out of the feed rather than being
|
|
217
|
+
// told it separately -- the key is inside the string as xs=urn:btpk:.
|
|
218
|
+
mutableMagnet: tag(block, 'pmtiles:mutable'),
|
|
199
219
|
magnet,
|
|
200
220
|
torrentUrl,
|
|
201
221
|
// RSS allows several <category> elements, and an archive may be tagged
|
package/src/watch.js
CHANGED
|
@@ -42,6 +42,7 @@ export class WatchManager {
|
|
|
42
42
|
#library;
|
|
43
43
|
#watchers = [];
|
|
44
44
|
#importing = new Set();
|
|
45
|
+
#ready = [];
|
|
45
46
|
|
|
46
47
|
/**
|
|
47
48
|
* Creates the manager.
|
|
@@ -142,6 +143,19 @@ export class WatchManager {
|
|
|
142
143
|
console.error(`[watch] ${directory}: ${error.message}`);
|
|
143
144
|
});
|
|
144
145
|
|
|
146
|
+
// chokidar scans the directory before it starts reporting changes, and
|
|
147
|
+
// a file that lands during that scan is in neither the listing nor the
|
|
148
|
+
// event stream. Nothing in the daemon depends on knowing when the scan
|
|
149
|
+
// ends — an archive dropped that early is picked up by the next one —
|
|
150
|
+
// but anything driving this deliberately does, and a caller with no way
|
|
151
|
+
// to know is a caller that has to guess with a sleep.
|
|
152
|
+
this.#ready.push(
|
|
153
|
+
new Promise((resolve) => {
|
|
154
|
+
watcher.once('ready', resolve);
|
|
155
|
+
watcher.once('error', resolve);
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
|
|
145
159
|
this.#watchers.push(watcher);
|
|
146
160
|
for (const entry of entries) {
|
|
147
161
|
const tags = normalizeCategories(entry);
|
|
@@ -266,5 +280,21 @@ export class WatchManager {
|
|
|
266
280
|
async stop() {
|
|
267
281
|
await Promise.all(this.#watchers.map((watcher) => watcher.close()));
|
|
268
282
|
this.#watchers = [];
|
|
283
|
+
this.#ready = [];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Resolves once every watcher has finished its first scan.
|
|
288
|
+
*
|
|
289
|
+
* Until then a file dropped into a watched folder may be reported as an
|
|
290
|
+
* event, may be found by the scan, or may be missed by both — chokidar makes
|
|
291
|
+
* no promise about the window between opening a watch and finishing the
|
|
292
|
+
* listing. Nothing in the daemon needs this, since an archive that early is
|
|
293
|
+
* picked up on the next pass, but anything that creates a file and then
|
|
294
|
+
* expects to see it imported is racing without it.
|
|
295
|
+
* @returns {Promise<void>} - Resolves when watching has actually begun.
|
|
296
|
+
*/
|
|
297
|
+
async ready() {
|
|
298
|
+
await Promise.all(this.#ready);
|
|
269
299
|
}
|
|
270
300
|
}
|
package/src/web/public.html
CHANGED
|
@@ -3,10 +3,14 @@
|
|
|
3
3
|
The public front door, served at / on the public listener when the console
|
|
4
4
|
has an admin port of its own.
|
|
5
5
|
|
|
6
|
-
Reads /
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
Reads /latest/ for the categories and, for the archives, /api/catalog where a
|
|
7
|
+
credential allows it and /feed.xml where one does not — everything under /api/
|
|
8
|
+
is guarded on a node with any authentication configured, which is most of them,
|
|
9
|
+
and a front page that answers 401 to its own audience is worse than none.
|
|
10
|
+
|
|
11
|
+
All three are filtered by feedCategories, so this page shows exactly what the
|
|
12
|
+
node publishes and cannot show anything else. It is deliberately plain: no
|
|
13
|
+
build step, no dependencies, and it degrades to a readable list of links if the
|
|
10
14
|
script does not run.
|
|
11
15
|
-->
|
|
12
16
|
<html lang="en">
|
|
@@ -167,7 +171,7 @@
|
|
|
167
171
|
<noscript>
|
|
168
172
|
<p class="failed">
|
|
169
173
|
This page lists the archives using JavaScript. Without it, the same
|
|
170
|
-
information is at <a href="/
|
|
174
|
+
information is at <a href="/latest/">/latest/</a> as JSON and
|
|
171
175
|
<a href="/feed.xml">/feed.xml</a> as RSS.
|
|
172
176
|
</p>
|
|
173
177
|
</noscript>
|
|
@@ -176,8 +180,7 @@
|
|
|
176
180
|
<div id="archives"><p class="empty">Loading…</p></div>
|
|
177
181
|
|
|
178
182
|
<footer>
|
|
179
|
-
<a href="/
|
|
180
|
-
<a href="/api/categories">categories JSON</a> ·
|
|
183
|
+
<a href="/latest/">categories JSON</a> ·
|
|
181
184
|
<a href="/feed.xml">RSS feed</a> ·
|
|
182
185
|
<a href="/health">health</a>
|
|
183
186
|
</footer>
|
|
@@ -280,47 +283,176 @@
|
|
|
280
283
|
|
|
281
284
|
// Categories are the handle that survives a rebuild, so they go first —
|
|
282
285
|
// a style should point at one of these rather than at an infohash.
|
|
286
|
+
// Categories first, and given the same weight as an archive, because
|
|
287
|
+
// for most visitors they are the more useful thing: an archive URL names
|
|
288
|
+
// one build and goes stale on the next, while a category names whichever
|
|
289
|
+
// is current and keeps working across rebuilds. A style should point at
|
|
290
|
+
// one of these.
|
|
283
291
|
const renderCategories = (categories) => {
|
|
284
292
|
const host = document.getElementById('categories');
|
|
285
|
-
|
|
293
|
+
host.textContent = '';
|
|
294
|
+
const usable = categories.filter((c) => c.endpoints);
|
|
286
295
|
if (usable.length === 0) return;
|
|
287
296
|
|
|
288
297
|
host.append(el('h2', null, 'Categories'));
|
|
289
|
-
|
|
298
|
+
host.append(
|
|
299
|
+
el(
|
|
300
|
+
'p',
|
|
301
|
+
'facts',
|
|
302
|
+
'These follow the newest build. Point a style at one of these ' +
|
|
303
|
+
'rather than at an archive below, and it keeps working when the ' +
|
|
304
|
+
'archive is rebuilt.',
|
|
305
|
+
),
|
|
306
|
+
);
|
|
307
|
+
|
|
290
308
|
for (const entry of usable) {
|
|
291
|
-
// `category` and `archives` are what
|
|
309
|
+
// `category` and `archives` are what the endpoint actually calls
|
|
292
310
|
// these; `name` and `count` would read better and are not there.
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
311
|
+
const card = el('div', 'archive');
|
|
312
|
+
const head = el('header');
|
|
313
|
+
head.append(el('span', 'name', entry.category));
|
|
314
|
+
if (Number.isFinite(entry.archives)) {
|
|
315
|
+
head.append(
|
|
316
|
+
el(
|
|
317
|
+
'span',
|
|
318
|
+
'tag',
|
|
319
|
+
`${entry.archives} build${entry.archives === 1 ? '' : 's'}`,
|
|
320
|
+
),
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
card.append(head);
|
|
324
|
+
|
|
325
|
+
if (entry.newest?.name) {
|
|
326
|
+
const facts = [`newest: ${entry.newest.name}`];
|
|
327
|
+
const size = bytes(entry.newest.size);
|
|
328
|
+
if (size) facts.push(size);
|
|
329
|
+
card.append(el('p', 'facts', facts.join(' · ')));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const links = el('div', 'links');
|
|
333
|
+
const add = (href, text) => {
|
|
334
|
+
const a = el('a', null, text);
|
|
335
|
+
a.href = href;
|
|
336
|
+
links.append(a);
|
|
337
|
+
};
|
|
338
|
+
const ends = entry.endpoints;
|
|
339
|
+
if (ends.tileJson) add(ends.tileJson, 'TileJSON');
|
|
340
|
+
if (ends.torrent) add(ends.torrent, '.torrent');
|
|
341
|
+
if (ends.magnet) add(ends.magnet, 'magnet');
|
|
342
|
+
if (ends.feed) add(ends.feed, 'RSS');
|
|
343
|
+
card.append(links);
|
|
344
|
+
|
|
345
|
+
const urls = el('div', 'urls');
|
|
346
|
+
// The one worth copying: the TileJSON URL with the magnet in its
|
|
347
|
+
// fragment, which a plain client fetches over HTTP and a swarm-aware
|
|
348
|
+
// one joins directly.
|
|
349
|
+
if (ends.styleUrl) {
|
|
350
|
+
urls.append(
|
|
351
|
+
urlRow('style URL', new URL(ends.styleUrl, location.href).href),
|
|
352
|
+
);
|
|
353
|
+
} else if (ends.tileJson) {
|
|
354
|
+
urls.append(
|
|
355
|
+
urlRow('TileJSON', new URL(ends.tileJson, location.href).href),
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
if (urls.childElementCount > 0) card.append(urls);
|
|
359
|
+
|
|
360
|
+
host.append(card);
|
|
299
361
|
}
|
|
300
|
-
|
|
362
|
+
|
|
301
363
|
host.append(el('h2', null, 'Archives'));
|
|
302
364
|
};
|
|
365
|
+
const PMTILES_NS =
|
|
366
|
+
'https://github.com/TechIdiots-LLC/pmtiles-swarm/ns/1.0';
|
|
367
|
+
|
|
368
|
+
// The feed says the same things the catalog does, in RSS. It is the
|
|
369
|
+
// fallback because it is the one that needs no credential: /api/catalog
|
|
370
|
+
// is guarded whenever this node has any authentication configured, which
|
|
371
|
+
// is most of them, and a front page that 401s is worse than none.
|
|
372
|
+
const fromFeed = (xmlText) => {
|
|
373
|
+
const doc = new DOMParser().parseFromString(xmlText, 'application/xml');
|
|
374
|
+
if (doc.querySelector('parsererror')) {
|
|
375
|
+
throw new Error('the feed is not valid XML');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const text = (item, name) =>
|
|
379
|
+
item.getElementsByTagNameNS(PMTILES_NS, name)[0]?.textContent ??
|
|
380
|
+
undefined;
|
|
381
|
+
|
|
382
|
+
return [...doc.getElementsByTagName('item')].map((item) => {
|
|
383
|
+
const zoom = (name) => {
|
|
384
|
+
const value = Number(text(item, name));
|
|
385
|
+
return Number.isFinite(value) ? value : undefined;
|
|
386
|
+
};
|
|
387
|
+
const enclosure = item.getElementsByTagName('enclosure')[0];
|
|
388
|
+
const format = text(item, 'format');
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
infoHash: text(item, 'infohash'),
|
|
392
|
+
name: item.getElementsByTagName('title')[0]?.textContent,
|
|
393
|
+
size: Number(enclosure?.getAttribute('length')) || undefined,
|
|
394
|
+
categories: [...item.getElementsByTagName('category')].map(
|
|
395
|
+
(node) => node.textContent,
|
|
396
|
+
),
|
|
397
|
+
magnet: text(item, 'magnet'),
|
|
398
|
+
createdAt: item.getElementsByTagName('pubDate')[0]?.textContent,
|
|
399
|
+
pmtiles: format
|
|
400
|
+
? {
|
|
401
|
+
format,
|
|
402
|
+
minZoom: zoom('minzoom'),
|
|
403
|
+
maxZoom: zoom('maxzoom'),
|
|
404
|
+
}
|
|
405
|
+
: undefined,
|
|
406
|
+
};
|
|
407
|
+
});
|
|
408
|
+
};
|
|
303
409
|
|
|
304
410
|
const load = async () => {
|
|
411
|
+
let archives = null;
|
|
412
|
+
|
|
413
|
+
// Richer where it is reachable: the catalog carries web seeds and the
|
|
414
|
+
// sparse flag, which the feed does not.
|
|
305
415
|
try {
|
|
306
416
|
const response = await fetch('/api/catalog');
|
|
307
|
-
if (
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
} catch
|
|
311
|
-
|
|
312
|
-
host.textContent = '';
|
|
313
|
-
host.append(
|
|
314
|
-
el('p', 'failed', `Could not load the catalog: ${error.message}`),
|
|
315
|
-
);
|
|
316
|
-
return;
|
|
417
|
+
if (response.ok) {
|
|
418
|
+
archives = (await response.json()).archives ?? [];
|
|
419
|
+
}
|
|
420
|
+
} catch {
|
|
421
|
+
// Fall through to the feed.
|
|
317
422
|
}
|
|
318
423
|
|
|
319
|
-
|
|
320
|
-
|
|
424
|
+
if (archives === null) {
|
|
425
|
+
try {
|
|
426
|
+
const response = await fetch('/feed.xml');
|
|
427
|
+
if (!response.ok) throw new Error(`feed said ${response.status}`);
|
|
428
|
+
archives = fromFeed(await response.text());
|
|
429
|
+
} catch (error) {
|
|
430
|
+
const host = document.getElementById('archives');
|
|
431
|
+
host.textContent = '';
|
|
432
|
+
host.append(
|
|
433
|
+
el(
|
|
434
|
+
'p',
|
|
435
|
+
'failed',
|
|
436
|
+
`Could not load the archives: ${error.message}`,
|
|
437
|
+
),
|
|
438
|
+
);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
render(archives.filter((archive) => archive.infoHash));
|
|
444
|
+
|
|
445
|
+
// /latest/ rather than /api/categories: the same list, without a
|
|
446
|
+
// credential. This is the part most visitors actually want -- an
|
|
447
|
+
// archive URL names one build, a category names whichever is current
|
|
448
|
+
// -- so it is worth more than a best-effort attempt at a guarded
|
|
449
|
+
// endpoint that answers 401 to exactly the audience this page is for.
|
|
321
450
|
try {
|
|
322
|
-
const response = await fetch('/
|
|
323
|
-
if (response.ok)
|
|
451
|
+
const response = await fetch('/latest/');
|
|
452
|
+
if (response.ok) {
|
|
453
|
+
const body = await response.json();
|
|
454
|
+
renderCategories(body.categories ?? []);
|
|
455
|
+
}
|
|
324
456
|
} catch {
|
|
325
457
|
// Nothing to say. The archives above are the substance.
|
|
326
458
|
}
|