Keep published geocaches available without the live bridge

This commit is contained in:
liu weikai
2026-09-28 17:30:23 +08:00
parent c43b372aff
commit 2a30e2e0be
13 changed files with 248 additions and 15 deletions
+1
View File
@@ -46,6 +46,7 @@ jobs:
npm run build:geocaching
node --test ../tests/site/geocaching-protocol.test.mjs
node --test ../tests/site/geocaching-regions.test.mjs
node --test ../tests/site/geocaching-snapshot.test.mjs
- name: Overlay the Geocaching page and navigation
run: |
cp -a site/geocaching "$RUNNER_TEMP/publish/"
+1
View File
@@ -102,6 +102,7 @@ jobs:
npm run build:components
node build-geocaching.mjs
node --test ../tests/site/geocaching-protocol.test.mjs
node --test ../tests/site/geocaching-snapshot.test.mjs
rm -rf node_modules
- name: Upload GitHub Pages artifact
+2
View File
@@ -0,0 +1,2 @@
!objects/
!objects/*.bin
+24
View File
@@ -0,0 +1,24 @@
# Published public directory
The map loads this directory even when its live Reticulum bridge is offline.
`index.json` includes the export time and current public summaries. Immutable
signed objects are fetched on demand and verified against their summaries before
displaying details or producing GPX. Archived heads are retained so archive
filters and later exports preserve the publisher's state.
This is a dated copy, not a live global inventory. An unavailable bridge must not
be presented as an empty live result. The worker can use live directories when
available and falls back to the published copy when a live query fails.
To refresh from an operator's directory database:
```sh
python tools/geocaching/export_public_directory.py --database /path/to/directory.sqlite --output site/geocaching/data/public
```
Run `node --test tests/site/geocaching-snapshot.test.mjs` and publish the resulting
files through the normal Pages deployment. The exporter opens SQLite read-only,
verifies each signed object, excludes conflicting heads, and exports only public
records. Never copy a service state directory, identity file or SQLite database
into the website. Automatic collection and deployment still require an available
directory source and an authorized scheduled deployment.
+1
View File
@@ -0,0 +1 @@
{"version":1,"updatedAt":"2026-09-28T09:25:41.666848+00:00","summaries":["m8QgPjDvQphX74IMV5HHSMCxfnI5sftPiWd2GooXGm1BvoICxCB8R9Y20Voo4TFwaXef3IGHgER4nHqyrnLe17eagCz3LwIAALFBcmNoaXZlZCBsb2NhdGlvbgICAM0BBg==","m8QgkeI2uB8xiX5BTqpHoVUqvLkZp4OyZ5a5Ki04f7mGtMwBxCDNtFrWgLOpafU+MDYGnyH5SyBvyUFPg2VECHqL3ZrGkwDOBfXhANLSs1IAsFNhbiBKb3NlIEdhbGxlb24CAgDNAW0=","m8Qg31G0+16F7BrdNsYbLAahfe4UyiQcm7hsCuoKO3QeE5gCxCAJSCAIyc/6t1oylnbiFnoATIeWWlKSnwBnpyap3L70EwLOBfXhANLSs1IAsUFyY2hpdmVkIGxvY2F0aW9uAgIAzQEO"]}
+25 -5
View File
@@ -17,7 +17,7 @@ const labels = {
show:tr('Show caches','显示藏宝点'),active:tr('Active','开放'),disabled:tr('Disabled','停用'),archived:tr('Archived','归档'),
emptyCount:tr('Loaded 0 caches','已加载 0 个藏宝点'),start:tr('Preparing your map…','正在准备你的寻宝地图…'),
more:tr('Load more','加载更多'),downloadSelected:tr('Download selected GPX','下载所选 GPX'),downloadPartial:tr('Download successful items only','仅下载成功的项目'),
scope:tr('Results cover the directories reached in this session, not a global total.','结果来自本次连接到的目录,数量仅指已加载内容,不代表全网总数。'),
scope:tr('Results cover published copies or live directories, not a global total.','结果来自已同步副本或实时目录,数量不代表全网总数。'),
search:tr('Search this area','搜索此区域'),download:tr('Download GPX','下载 GPX'),
region:tr('Where to explore','探索范围'),regionKind:tr('Search by','按范围查找'),
mapRegion:tr('Current map area','当前地图区域'),countries:tr('Country / region','国家/地区'),seas:tr('Ocean / sea','海洋/海域'),
@@ -28,6 +28,7 @@ for (const node of document.querySelectorAll('[data-label]')) node.textContent =
const $ = id => document.getElementById(id);
const worker = new Worker(new URL('./reticulum-worker.js', import.meta.url), {type:'module'});
let nextId = 0, ready = false, rows = [], detailId = null, detailGeneration = 0, partial = null;
let snapshotAt = null;
const pending = new Map(), selected = new Set();
const map = L.map('map', {worldCopyJump:true, minZoom:0}).fitWorld();
const tiles = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {maxZoom:19, updateWhenIdle:true,
@@ -153,7 +154,7 @@ function renderMarkers() {
for (const cluster of cells.values()) {
const {row,lat,lon} = cluster[0], multi = cluster.length > 1;
const content = document.createElement('span'), image = document.createElement('img');
image.src = cacheIconUrl; image.alt = ''; image.width = image.height = 32;
image.src = new URL(cacheIconUrl, import.meta.url).href; image.alt = ''; image.width = image.height = 32;
content.append(image);
if (multi) {
const count = document.createElement('span');
@@ -176,13 +177,14 @@ async function openDetail(row) {
try {
const item = await rpc('get',{cacheId:row.id});
if (generation !== detailGeneration) return;
$('detail-state').textContent = item.isCurrent ? tr('AUTHOR SIGNATURE VERIFIED','作者签名已验证') : tr('VERIFIED HISTORICAL VERSION','已验证的历史版本');
$('detail-state').textContent = item.snapshotAt ? tr('VERIFIED PUBLISHED COPY','已验证的公开副本') : item.isCurrent ? tr('AUTHOR SIGNATURE VERIFIED','作者签名已验证') : tr('VERIFIED HISTORICAL VERSION','已验证的历史版本');
const description = document.createElement('p'); description.textContent = item.record[9];
const hint = document.createElement('details'), summary = document.createElement('summary'), text = document.createElement('p');
summary.textContent = tr('Show hint','展开提示'); text.textContent = item.record[10] || tr('No hint provided.','未提供提示。'); hint.append(summary,text);
const metadata = document.createElement('p'); metadata.className = 'fingerprint';
metadata.textContent = `${tr('Author','作者')}: ${item.authorHash}\n${tr('Version','版本')}: ${item.record[3]}\n` +
`${tr('Verified at','验证时间')}: ${new Date(item.checkedAt).toLocaleString()}\n${item.sourceName}`;
`${tr('Verified at','验证时间')}: ${new Date(item.checkedAt).toLocaleString()}\n${item.sourceName}` +
(item.snapshotAt ? `\n${tr('Directory synced','目录同步时间')}: ${new Date(item.snapshotAt).toLocaleString()}` : '');
$('detail-content').append(description,hint,metadata); $('download-one').disabled = false;
} catch (error) { if (generation === detailGeneration) $('detail-state').textContent = error.message; }
}
@@ -216,7 +218,17 @@ worker.onmessage = ({data}) => {
return;
}
const event=data.event;
if (event.type==='status') {
if (event.type==='snapshot') {
snapshotAt = event.updatedAt; ready=true;
$('search').disabled=false; $('region-apply').disabled=false;
$('connection-state').textContent=tr('Published directory','已同步目录');
$('directory-status').textContent=tr('Directory synced: ','目录同步时间:')+new Date(snapshotAt).toLocaleString();
initialLocation.finally(()=>{if(ready && querySerial===0)search();});
} else if (event.type==='status') {
if (snapshotAt) {
$('connection-state').textContent=tr('Published directory','已同步目录');
return;
}
$('connection-state').textContent = event.state==='disconnected'?tr('Disconnected','连接已断开'):tr('Discovering directories…','正在发现目录…');
if (event.state==='disconnected') { ready=false; $('search').disabled=true; $('region-apply').disabled=true; $('connection-state').classList.remove('ready'); updateSelection(); }
} else if (event.type==='directory') {
@@ -229,11 +241,14 @@ worker.onmessage = ({data}) => {
rows=event.rows; $('more').hidden=!event.more; renderRows();
notice(event.limited?tr('500-item display limit reached. Narrow the area to explore more.','已达到 500 项显示上限,请缩小区域继续探索。'):
!rows.length && event.more?tr('No matches in these pages yet. Load more to continue searching this region.','当前批次暂无匹配点,可加载更多继续检索此区域。'):
event.snapshotAt ? tr('Showing the published directory. Last synced: ','正在显示已同步目录,最后同步:')+new Date(event.snapshotAt).toLocaleString() :
tr('Loaded from live directory responses. Open a cache to verify its details.','已加载目录实时响应。打开藏宝点以验证完整详情。'));
} else if (event.type==='source-error') notice(`${event.name}: ${event.message}`);
};
worker.onerror = () => { notice(tr('The map service stopped. Refresh the page to try again.','地图服务已停止,请刷新页面重试。')); ready=false; $('search').disabled=true; $('region-apply').disabled=true; };
async function startService() {
try { await rpc('snapshot', {url:new URL('../data/public/index.json', import.meta.url).href}); }
catch { /* Live discovery can still work when no published copy is available. */ }
try {
const response = await fetch(new URL('../network.json', import.meta.url), {cache:'no-store'});
if (!response.ok) throw Error('Missing deployment configuration');
@@ -242,6 +257,11 @@ async function startService() {
if (url.protocol !== 'wss:' && !(url.protocol === 'ws:' && ['localhost','127.0.0.1','[::1]'].includes(url.hostname))) throw Error('Invalid deployment endpoint');
await rpc('connect',{url:url.href,discoverySeeds:config.discoverySeeds});
} catch {
if (snapshotAt) {
$('connection-state').textContent=tr('Published directory','已同步目录');
notice(tr('Live updates unavailable. Published caches, details and GPX remain accessible.','实时更新暂不可用,仍可查看已同步藏宝点、详情并下载 GPX。'));
return;
}
$('connection-state').textContent = tr('Service unavailable','服务暂不可用');
$('directory-status').textContent = tr('Please try again later.','请稍后再试。');
notice(tr('The map service is temporarily unavailable. No setup is needed on your side.','寻宝地图服务暂时不可用,你无需进行任何网络设置。'));
+31 -10
View File
@@ -1,27 +1,48 @@
import {DirectoryClient} from './protocol-client.js';
import {makeGpx} from './gpx-download.js';
import {SnapshotClient} from './snapshot-client.js';
let client = null;
let client = null, snapshot = null, liveReady = false, selected = null;
const notify = event => self.postMessage({event});
self.onmessage = async ({data}) => {
const {id, command, args = {}} = data;
try {
let result;
if (command === 'connect') {
if (command === 'snapshot') {
const candidate = new SnapshotClient(notify);
result = await candidate.load(args.url);
snapshot = candidate;
notify({type:'snapshot', updatedAt:result});
} else if (command === 'connect') {
await client?.close();
client = new DirectoryClient(event => self.postMessage({event}));
liveReady = false;
client = new DirectoryClient(event => {
if (event.type === 'directory') liveReady = true;
if (event.type === 'status' && event.state === 'disconnected') liveReady = false;
notify(event);
});
await client.connect(args.url, null, args.discoverySeeds);
} else if (command === 'disconnect') {
await client?.close(); client = null;
await client?.close(); client = null; liveReady = false;
} else {
if (!client) throw Error('Connect to Reticulum first');
if (command === 'query') await client.query(args.bounds, args.stateMask, args.region, args.queryToken);
else if (command === 'more') await client.more();
else if (command === 'get') result = await client.get(args.cacheId);
if (!client && !snapshot) throw Error('Directory unavailable');
if (command === 'query') {
selected = liveReady ? client : snapshot;
if (!selected) throw Error('Still discovering public directories');
try { await selected.query(args.bounds, args.stateMask, args.region, args.queryToken); }
catch (error) {
if (!snapshot || selected === snapshot) throw error;
selected = snapshot;
await selected.query(args.bounds, args.stateMask, args.region, args.queryToken);
}
}
else if (command === 'more') { if (selected === client) await client.more(); }
else if (command === 'get') result = await selected.get(args.cacheId);
else if (command === 'download') {
if (!Array.isArray(args.cacheIds) || !args.cacheIds.length || args.cacheIds.length > 20) throw Error('Select 1–20 caches');
const records = [], failures = [];
const records = [], failures = [], provider = selected;
for (const cacheId of args.cacheIds) {
try { records.push((await client.get(cacheId)).signed); }
try { records.push((await provider.get(cacheId)).signed); }
catch (error) { failures.push({cacheId, message: error.message}); }
}
result = {gpx: records.length ? await makeGpx(records) : null, succeeded: records.length, failures};
+52
View File
@@ -0,0 +1,52 @@
import {decode, validateSummary, verifySigned, viewportBoxes} from './protocol.js';
import {countryContains} from './country-boundaries.js';
const hex = bytes => Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('');
// Public summaries load independently of WSS. Full objects remain immutable,
// are fetched on demand, and use the same author verification as live replies.
export class SnapshotClient {
constructor(notify, fetcher = (...args) => fetch(...args)) { this.notify = notify; this.fetcher = fetcher; this.rows = new Map(); }
async load(url) {
this.base = new URL('.', url);
const response = await this.fetcher(url, {cache:'no-cache', signal:AbortSignal.timeout(15000)});
if (!response.ok) throw Error('Published directory unavailable');
const text = await response.text();
if (text.length > 16 * 1024 * 1024) throw Error('Published directory too large');
const data = JSON.parse(text);
if (data.version !== 1 || !Number.isFinite(Date.parse(data.updatedAt)) || !Array.isArray(data.summaries) || data.summaries.length > 20000) throw Error('Invalid published directory');
const rows = new Map();
for (const encoded of data.summaries) {
if (typeof encoded !== 'string' || encoded.length > 1024) throw Error('Invalid published summary');
const summary = validateSummary(decode(Uint8Array.from(atob(encoded), c => c.charCodeAt(0))));
const id = hex(summary[0]);
if (rows.has(id)) throw Error('Duplicate published cache');
rows.set(id, {id, summary, conflict:false, sourceName:'Published directory'});
}
this.rows = rows; this.updatedAt = data.updatedAt;
return this.updatedAt;
}
query(bounds, stateMask, region, queryToken) {
const boxes = viewportBoxes(bounds), found = [];
for (const row of this.rows.values()) {
const s = row.summary;
if (!(stateMask & (1 << s[3])) || !boxes.some(([south,west,north,east]) => s[4] >= south && s[4] <= north && s[5] >= west && s[5] <= east)) continue;
if (region && !countryContains(region, s[4]/1e7, s[5]/1e7)) continue;
found.push(row);
if (found.length > 500) break;
}
this.notify({type:'results', queryToken, rows:found.slice(0,500), more:false, limited:found.length > 500, snapshotAt:this.updatedAt});
}
async get(id) {
const row = this.rows.get(id);
if (!row) throw Error('Cache not present in published directory');
const response = await this.fetcher(new URL(`objects/${hex(row.summary[2])}.bin`, this.base), {signal:AbortSignal.timeout(15000)});
if (!response.ok) throw Error('Published details unavailable');
const raw = new Uint8Array(await response.arrayBuffer());
const verified = await verifySigned(decode(raw, 8192), row.summary);
return {...verified, isCurrent:false, snapshotAt:this.updatedAt, checkedAt:Date.now(), sourceName:'Published directory'};
}
}
+69
View File
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import {test} from 'node:test';
import {SnapshotClient} from '../../site/geocaching/src/snapshot-client.js';
import {decode, encode, verifySigned} from '../../site/geocaching/src/protocol.js';
import {DirectoryClient} from '../../site/geocaching/src/protocol-client.js';
const bytes = await readFile(new URL('../../modules/core_geocaching/tests/fixtures/signed-cache-v1.bin', import.meta.url));
const verified = await verifySigned(decode(bytes));
const manifest = {version:1, updatedAt:'2026-09-28T00:00:00Z', summaries:[Buffer.from(encode(verified.summary)).toString('base64')]};
const fetcher = async url => new Response(String(url).endsWith('index.json') ? JSON.stringify(manifest) : bytes);
test('published directory filters locally and verifies full signed details', async () => {
let event;
const client = new SnapshotClient(value => event=value, fetcher);
await client.load('https://example.org/geocaching/data/public/index.json');
client.query([-90,-180,90,180],1,null,7);
assert.equal(event.rows.length,1);
assert.equal(event.queryToken,7);
assert.equal(event.snapshotAt,manifest.updatedAt);
const detail = await client.get(verified.cacheId);
assert.equal(detail.record[9],verified.record[9]);
assert.equal(detail.isCurrent,false);
client.query([-90,-180,90,180],4,null,8);
assert.equal(event.rows.length,0);
client.query([-90,-180,90,180],1,{type:'Feature',geometry:{type:'Polygon',coordinates:[[[0,0],[1,0],[1,1],[0,1],[0,0]]]}},9);
assert.equal(event.rows.length,0);
const damaged=Uint8Array.from(bytes); damaged[damaged.length-1]^=1;
client.fetcher=async()=>new Response(damaged);
await assert.rejects(client.get(verified.cacheId),/signature/);
});
test('worker keeps query, details and GPX available when WSS cannot connect', async () => {
const events=[], originalFetch=globalThis.fetch, originalConnect=DirectoryClient.prototype.connect;
globalThis.self={postMessage:value=>events.push(value)};
globalThis.fetch=fetcher;
DirectoryClient.prototype.connect=async()=>{throw Error('Bridge offline');};
try {
await import('../../site/geocaching/src/reticulum-worker.js');
await self.onmessage({data:{id:1,command:'snapshot',args:{url:'https://example.org/data/public/index.json'}}});
await self.onmessage({data:{id:2,command:'connect',args:{url:'wss://offline.example.org'}}});
assert.equal(events.find(value=>value.id===2).error,'Bridge offline');
await self.onmessage({data:{id:3,command:'query',args:{bounds:[-90,-180,90,180],stateMask:1,queryToken:1}}});
assert.equal(events.find(value=>value.event?.type==='results').event.rows.length,1);
await self.onmessage({data:{id:4,command:'get',args:{cacheId:verified.cacheId}}});
assert.equal(events.find(value=>value.id===4).result.record[9],verified.record[9]);
await self.onmessage({data:{id:5,command:'download',args:{cacheIds:[verified.cacheId]}}});
const download=events.find(value=>value.id===5).result;
assert.equal(download.succeeded,1); assert.equal(download.failures.length,0);
assert.match(download.gpx,/<gpx/);
} finally {
globalThis.fetch=originalFetch; DirectoryClient.prototype.connect=originalConnect;
delete globalThis.self;
}
});
test('published San Jose record remains readable and verifies against its summary', async () => {
const base=new URL('../../site/geocaching/data/public/',import.meta.url);
const fetchFiles=async url=>new Response(await readFile(new URL(url)));
let event;
const client=new SnapshotClient(value=>event=value,fetchFiles);
await client.load(new URL('index.json',base));
client.query([-90,-180,90,180],1,null,1);
const row=event.rows.find(row=>row.summary[6]==='San Jose Galleon');
assert.ok(row);
const detail=await client.get(row.id);
assert.ok(detail.record[9].length>20);
assert.equal(detail.cacheId,row.id);
});
@@ -0,0 +1,42 @@
"""Export current public signed records for GitHub Pages, without private state."""
import argparse
import base64
from datetime import datetime, timezone
import json
from pathlib import Path
import sqlite3
from directory_store import packed, verify_cache
def export(database, output):
output = Path(output)
objects = output / "objects"
objects.mkdir(parents=True, exist_ok=True)
summaries = []
with sqlite3.connect(Path(database).resolve().as_uri() + "?mode=ro", uri=True) as db:
db.execute("BEGIN")
rows = db.execute("""SELECT o.cache, o.hash, o.raw, o.signature FROM heads h
JOIN objects o ON o.hash=h.hash JOIN public_refs p ON p.hash=o.hash
WHERE h.conflict=0 ORDER BY o.cache""")
for cache_id, revision_hash, raw, signature in rows:
verified = verify_cache([raw, signature])
if verified.cache_id != cache_id or verified.revision_hash != revision_hash:
raise ValueError("Stored object identity mismatch")
(objects / f"{revision_hash.hex()}.bin").write_bytes(packed([raw, signature]))
summaries.append(base64.b64encode(packed(verified.summary)).decode("ascii"))
if len(summaries) > 20000:
raise ValueError("Published directory requires partitioning above 20000 caches")
data = {"version": 1, "updatedAt": datetime.now(timezone.utc).isoformat(), "summaries": summaries}
temporary = output / "index.json.tmp"
temporary.write_text(json.dumps(data, ensure_ascii=True, separators=(",", ":")) + "\n", encoding="utf-8")
temporary.replace(output / "index.json")
return len(summaries)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--database", required=True)
parser.add_argument("--output", required=True)
args = parser.parse_args()
print(f"Exported {export(args.database, args.output)} public caches")