sailkick-boat 0.21.2 → 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/README.md +36 -3
- package/index.js +43 -0
- package/lib/cloud/index.js +226 -0
- package/lib/proxy/index.js +22 -0
- package/package.json +1 -1
- package/public/index.html +1 -0
- package/public/sync.html +233 -0
package/README.md
CHANGED
|
@@ -242,6 +242,15 @@ so the boat's own app opens with no password, fully offline. Everything else in
|
|
|
242
242
|
config passes through untouched. (Hand-edit `proxy.openAccess: false` to keep the cloud
|
|
243
243
|
login gate — there is no toggle, since the gate cannot complete over the mirror anyway.)
|
|
244
244
|
|
|
245
|
+
It also fills in **`boat.perfKey`**, which the cloud only sends to a logged-in session.
|
|
246
|
+
Without it the app has no identity and the **performance data cloud** — the recorded
|
|
247
|
+
(TWA, STW) samples behind the polar plot, baked to `/perf/<perfKey>/estimate.json` — never
|
|
248
|
+
loads, even though the bake itself is cached and reachable. The key is derived rather than
|
|
249
|
+
configured: the app server takes a boat's Influx bucket and its perf directory from the
|
|
250
|
+
same identity, so the bucket minus its `_raw` suffix *is* the perf key, for a UUID account
|
|
251
|
+
and a grandfathered slug one alike. An unpaired boat has no bucket, so `boat` is left
|
|
252
|
+
exactly as the cloud sent it.
|
|
253
|
+
|
|
245
254
|
## Routes, polars and settings — stored on the boat
|
|
246
255
|
The app reads and writes these through `/api/profile/*`. On the cloud that router is
|
|
247
256
|
session-gated, and the mirror can never satisfy it: the caching GET path forwards no
|
|
@@ -255,9 +264,33 @@ directory (atomic writes, saves serialized so a burst from the route panel can't
|
|
|
255
264
|
itself). Same envelopes as the cloud, so the app can't tell the difference — and route
|
|
256
265
|
planning now works with no uplink at all, which is when you actually want it.
|
|
257
266
|
|
|
258
|
-
**This copy is boat-local
|
|
259
|
-
|
|
260
|
-
|
|
267
|
+
**This copy is boat-local.** A route saved on board stays on board; a route saved in the
|
|
268
|
+
web app stays in the cloud. They are not reconciled in the background — but you can copy
|
|
269
|
+
either way, item by item, from the **Sync polars & routes** page in the Signal K Webapps
|
|
270
|
+
menu.
|
|
271
|
+
|
|
272
|
+
### Sync page — copying polars and routes to and from the cloud
|
|
273
|
+
|
|
274
|
+
Sign in once with your sailkick account and the page lists polars and routes on both
|
|
275
|
+
sides, marking each **in sync**, **differs**, **boat only** or **cloud only**, with a
|
|
276
|
+
button to copy it either way. Nothing is deleted, and an item that already exists is only
|
|
277
|
+
overwritten after a confirmation.
|
|
278
|
+
|
|
279
|
+
The plugin holds the session, not the browser. That is what makes this possible at all:
|
|
280
|
+
the cloud session cookie is `HttpOnly; SameSite=Lax; Secure`, and the boat serves the app
|
|
281
|
+
over plain HTTP on a LAN address — a browser will not store a Secure cookie on an http
|
|
282
|
+
origin, will not send a Lax cookie cross-site, and blocks an https page from fetching http
|
|
283
|
+
at all. Those are all *browser* rules; the plugin is an ordinary HTTP client talking https
|
|
284
|
+
to the cloud, so none of them apply, and the browser only ever talks to the boat.
|
|
285
|
+
|
|
286
|
+
**Only the session is stored, never the password** — it buys a session and is discarded.
|
|
287
|
+
The session lasts 30 days, after which the page asks you to sign in again. The endpoints
|
|
288
|
+
live on the plugin's own router, which sits behind Signal K's security, rather than on the
|
|
289
|
+
open mirror port where anyone on the boat's wifi could use them.
|
|
290
|
+
|
|
291
|
+
Items are matched by **name**, since ids are assigned independently on each side. So a
|
|
292
|
+
polar you refined in the web app appears as *cloud only* — or *differs* if the boat has an
|
|
293
|
+
older one of the same name — and one click brings it aboard.
|
|
261
294
|
|
|
262
295
|
## Local history (offline Trends + track)
|
|
263
296
|
**One source: a live ring**, sampled from the same BoatState that feeds `/ws/telemetry`
|
package/index.js
CHANGED
|
@@ -10,6 +10,7 @@ const { createBackfill } = require('./lib/backfill')
|
|
|
10
10
|
const { createAis } = require('./lib/ais')
|
|
11
11
|
const { createAisTargets } = require('./lib/ais/targets')
|
|
12
12
|
const { createProfile } = require('./lib/profile')
|
|
13
|
+
const { createCloud } = require('./lib/cloud')
|
|
13
14
|
const { resolveAccountConfig } = require('./lib/account')
|
|
14
15
|
|
|
15
16
|
// sailkick-boat: one Signal K plugin, two independently-toggleable modules —
|
|
@@ -98,6 +99,7 @@ module.exports = function (app) {
|
|
|
98
99
|
let ais = null
|
|
99
100
|
let aisTargets = null
|
|
100
101
|
let profile = null
|
|
102
|
+
let cloud = null
|
|
101
103
|
let proxyPort = null // what the launcher page needs to build its links
|
|
102
104
|
let pairedSlug = null
|
|
103
105
|
let statusTimer = null
|
|
@@ -266,9 +268,17 @@ module.exports = function (app) {
|
|
|
266
268
|
if (upstream.ignored) {
|
|
267
269
|
;(app.error || console.error)(`[sailkick-boat] ignoring proxy.sailkickUrl "${upstream.ignored}" left over from an older config — mirroring ${upstream.url}. Set proxy.selfHosted:true to keep your own server.`)
|
|
268
270
|
}
|
|
271
|
+
// Boat identity for the app, derived — never guessed. The cloud only fills
|
|
272
|
+
// config.boat for a logged-in session and the mirror forwards no cookie, so the app
|
|
273
|
+
// ran with no identity and public/engine/polar-cloud.js could not find its data
|
|
274
|
+
// cloud (it keys on boat.perfKey). The app server defaults `bucket` and `perfKey`
|
|
275
|
+
// from the same identity (server/auth/registry.js), so the bucket minus its _raw
|
|
276
|
+
// suffix IS the perf key — for a UUID account and a grandfathered slug one alike.
|
|
277
|
+
const perfKey = b && b.bucket ? String(b.bucket).replace(/_raw$/, '') : null
|
|
269
278
|
const pOpts = {
|
|
270
279
|
sailkickUrl: upstream.url,
|
|
271
280
|
storeDir: store,
|
|
281
|
+
boat: perfKey ? { perfKey, slug: (b && b.slug) || perfKey } : null,
|
|
272
282
|
proxyPort: (proxyPort = p.proxyPort == null ? 8080 : p.proxyPort),
|
|
273
283
|
localSignalkUrl: p.localSignalkUrl || 'http://127.0.0.1:3000',
|
|
274
284
|
localPaths: (p.localPaths && p.localPaths.length) ? p.localPaths : PROXY_TUNING.localPaths,
|
|
@@ -326,6 +336,16 @@ module.exports = function (app) {
|
|
|
326
336
|
profile = null
|
|
327
337
|
}
|
|
328
338
|
|
|
339
|
+
// Cloud account session, for copying polars and routes between the boat and the
|
|
340
|
+
// cloud copy (the Sync page). Cookie only — see lib/cloud/index.js.
|
|
341
|
+
try {
|
|
342
|
+
cloud = createCloud(app, { upstream: SAILKICK_APP_URL, timeoutMs: PROXY_TUNING.requestTimeoutMs })
|
|
343
|
+
cloud.start()
|
|
344
|
+
} catch (e) {
|
|
345
|
+
(app.error || console.error)('[sailkick-boat] cloud session start failed: ' + e.message)
|
|
346
|
+
cloud = null
|
|
347
|
+
}
|
|
348
|
+
|
|
329
349
|
if (p.serveTelemetry !== false) {
|
|
330
350
|
try {
|
|
331
351
|
telemetry = createTelemetry(app, {})
|
|
@@ -452,6 +472,7 @@ module.exports = function (app) {
|
|
|
452
472
|
try { if (history) history.stop() } catch {}
|
|
453
473
|
try { if (aisTargets) aisTargets.stop() } catch {}
|
|
454
474
|
try { if (profile) profile.stop() } catch {}
|
|
475
|
+
try { if (cloud) cloud.stop() } catch {}
|
|
455
476
|
try { if (ais) ais.stop() } catch {}
|
|
456
477
|
try { if (backfill) backfill.stop() } catch {}
|
|
457
478
|
try { if (proxy) proxy.stop() } catch {}
|
|
@@ -462,6 +483,7 @@ module.exports = function (app) {
|
|
|
462
483
|
ais = null
|
|
463
484
|
aisTargets = null
|
|
464
485
|
profile = null
|
|
486
|
+
cloud = null
|
|
465
487
|
proxyPort = null
|
|
466
488
|
pairedSlug = null
|
|
467
489
|
proxy = null
|
|
@@ -485,6 +507,27 @@ module.exports = function (app) {
|
|
|
485
507
|
version: (() => { try { return require('./package.json').version } catch { return null } })()
|
|
486
508
|
})
|
|
487
509
|
})
|
|
510
|
+
// Cloud account endpoints for the Sync page. Deliberately HERE and not on the mirror:
|
|
511
|
+
// the plugin router sits behind Signal K's own security (this path answers 401
|
|
512
|
+
// unauthenticated, the mirror answers 200), so the cloud session is no more exposed
|
|
513
|
+
// than the admin UI. On :8080 it would be handed to anyone on the boat's wifi.
|
|
514
|
+
router.all('/cloud/*', (req, res) => {
|
|
515
|
+
if (!cloud) { res.status(503).json({ ok: false, code: 'off', message: 'plugin not enabled' }); return }
|
|
516
|
+
cloud.handle(String(req.params[0] || ''), req, res)
|
|
517
|
+
})
|
|
518
|
+
// The boat's OWN profile, mirrored onto this router so the Sync page can read both
|
|
519
|
+
// sides same-origin. It is already served on the mirror for the app itself, but the
|
|
520
|
+
// page is served from Signal K on :3000 and a cross-port fetch would need CORS.
|
|
521
|
+
router.all('/profile*', (req, res) => {
|
|
522
|
+
if (!profile) { res.status(503).json({ ok: false, code: 'off', message: 'plugin not enabled' }); return }
|
|
523
|
+
const rest = String(req.params[0] || '')
|
|
524
|
+
// profile.handle() parses an /api/profile/... url; give it one — query string
|
|
525
|
+
// included, or ?section= would be silently dropped and return the whole profile.
|
|
526
|
+
const qs = req.url.indexOf('?')
|
|
527
|
+
const url = '/api/profile' + rest + (qs >= 0 ? req.url.slice(qs) : '')
|
|
528
|
+
const shim = new Proxy(req, { get: (t, k) => (k === 'url' ? url : t[k]) })
|
|
529
|
+
profile.handle(shim, res)
|
|
530
|
+
})
|
|
488
531
|
router.get('/p/*', (req, res) => {
|
|
489
532
|
if (proxy) proxy.handleGet(req, res)
|
|
490
533
|
else res.status(503).send('proxy not enabled')
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Authenticated client for the boat's own cloud account, so the app on board can copy
|
|
4
|
+
// polars and routes to and from the cloud copy.
|
|
5
|
+
//
|
|
6
|
+
// Why the PLUGIN holds the credential rather than the browser: the cloud session cookie
|
|
7
|
+
// is `HttpOnly; SameSite=Lax; Secure` (server/auth/session.js), and the boat serves the
|
|
8
|
+
// app over plain HTTP on a LAN address. A browser will not store a Secure cookie on an
|
|
9
|
+
// http origin, will not send a Lax cookie cross-site, and blocks an https page from
|
|
10
|
+
// fetching http at all — so no arrangement of buttons in the browser can bridge the two.
|
|
11
|
+
// Every one of those is a BROWSER rule. Here the plugin is an ordinary HTTP client
|
|
12
|
+
// talking https to the cloud, so none of them apply, and the browser only ever talks to
|
|
13
|
+
// the boat, same-origin.
|
|
14
|
+
//
|
|
15
|
+
// COOKIE ONLY. The password is used once to obtain a session and is never written to
|
|
16
|
+
// disk: a stored password would let the boat act as the account indefinitely, which is a
|
|
17
|
+
// large escalation over the bucket-scoped write token sync uses. The cost is that the
|
|
18
|
+
// session expires (30 days, server/auth/session.js TTL_SEC) and has to be renewed by
|
|
19
|
+
// hand. That trade was chosen deliberately.
|
|
20
|
+
//
|
|
21
|
+
// These endpoints are mounted on the PLUGIN ROUTER, not the open mirror on :8080. The
|
|
22
|
+
// plugin router sits behind Signal K's own security (verified: /plugins/sailkick-boat/*
|
|
23
|
+
// answers 401 unauthenticated, the mirror answers 200), so the cloud session is no more
|
|
24
|
+
// exposed than the admin UI itself. On the mirror it would be handed to anyone on the
|
|
25
|
+
// boat's wifi.
|
|
26
|
+
|
|
27
|
+
const fs = require('fs')
|
|
28
|
+
const fsp = fs.promises
|
|
29
|
+
const path = require('path')
|
|
30
|
+
|
|
31
|
+
const COOKIE_NAME = 'sk_session'
|
|
32
|
+
const MAX_BODY_BYTES = 8 * 1024 * 1024 // a polar CSV is tiny; this is just a sane ceiling
|
|
33
|
+
|
|
34
|
+
function createCloud (app, options = {}) {
|
|
35
|
+
const log = (m) => (app.debug ? app.debug('[cloud] ' + m) : console.log('[sailkick-boat:cloud]', m))
|
|
36
|
+
const warn = (m) => (app.error ? app.error('[sailkick-boat:cloud] ' + m) : console.error('[sailkick-boat:cloud]', m))
|
|
37
|
+
|
|
38
|
+
const upstream = String(options.upstream || '').replace(/\/+$/, '')
|
|
39
|
+
const dataDir = options.dataDir || (app.getDataDirPath && app.getDataDirPath()) || '.'
|
|
40
|
+
const file = options.sessionFile || path.join(dataDir, 'cloud-session.json')
|
|
41
|
+
const timeoutMs = options.timeoutMs || 20000
|
|
42
|
+
|
|
43
|
+
let session = null // { cookie, slug, expiresAt }
|
|
44
|
+
|
|
45
|
+
function load () {
|
|
46
|
+
try {
|
|
47
|
+
const j = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
48
|
+
if (j && j.cookie) session = j
|
|
49
|
+
} catch { /* absent or corrupt — simply logged out */ }
|
|
50
|
+
if (session && session.expiresAt && Date.now() > session.expiresAt) {
|
|
51
|
+
log('stored cloud session had expired — logged out')
|
|
52
|
+
session = null
|
|
53
|
+
remove()
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function persist () {
|
|
58
|
+
try {
|
|
59
|
+
await fsp.mkdir(path.dirname(file), { recursive: true })
|
|
60
|
+
const tmp = `${file}.tmp-${process.pid}`
|
|
61
|
+
await fsp.writeFile(tmp, JSON.stringify(session), { mode: 0o600 })
|
|
62
|
+
await fsp.rename(tmp, file)
|
|
63
|
+
} catch (e) { warn('could not persist the cloud session: ' + e.message) }
|
|
64
|
+
}
|
|
65
|
+
function remove () { try { fs.unlinkSync(file) } catch {} }
|
|
66
|
+
|
|
67
|
+
// Pull our cookie out of a Set-Cookie response. getSetCookie() is the correct API when
|
|
68
|
+
// several cookies come back; fall back for older runtimes.
|
|
69
|
+
function readCookie (headers) {
|
|
70
|
+
const all = typeof headers.getSetCookie === 'function'
|
|
71
|
+
? headers.getSetCookie()
|
|
72
|
+
: [headers.get('set-cookie')].filter(Boolean)
|
|
73
|
+
for (const raw of all) {
|
|
74
|
+
const first = String(raw).split(';')[0]
|
|
75
|
+
if (first.startsWith(COOKIE_NAME + '=')) {
|
|
76
|
+
const maxAge = /max-age=(\d+)/i.exec(raw)
|
|
77
|
+
return { cookie: first, expiresAt: maxAge ? Date.now() + Number(maxAge[1]) * 1000 : null }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function login (slug, password) {
|
|
84
|
+
if (!upstream) return { ok: false, code: 'no-upstream', message: 'no cloud host configured' }
|
|
85
|
+
let r
|
|
86
|
+
try {
|
|
87
|
+
r = await fetch(`${upstream}/api/auth/login`, {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: { 'Content-Type': 'application/json' },
|
|
90
|
+
body: JSON.stringify({ slug, password }),
|
|
91
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
92
|
+
})
|
|
93
|
+
} catch (e) {
|
|
94
|
+
return { ok: false, code: 'offline', message: `cannot reach ${upstream} (${e.message})` }
|
|
95
|
+
}
|
|
96
|
+
if (r.status === 401) return { ok: false, code: 'bad-credentials', message: 'wrong boat name or password' }
|
|
97
|
+
if (!r.ok) return { ok: false, code: 'login-failed', message: `the cloud replied HTTP ${r.status}` }
|
|
98
|
+
const got = readCookie(r.headers)
|
|
99
|
+
if (!got) return { ok: false, code: 'no-session', message: 'the cloud accepted the login but sent no session' }
|
|
100
|
+
|
|
101
|
+
let name = slug
|
|
102
|
+
try { const j = await r.json(); name = (j && j.boat && (j.boat.slug || j.boat.name)) || slug } catch {}
|
|
103
|
+
session = { cookie: got.cookie, slug: name, expiresAt: got.expiresAt }
|
|
104
|
+
await persist()
|
|
105
|
+
log(`logged in to ${upstream} as ${name}${got.expiresAt ? `; session valid until ${new Date(got.expiresAt).toISOString().slice(0, 10)}` : ''}`)
|
|
106
|
+
return { ok: true, ...status() }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function logout () {
|
|
110
|
+
session = null
|
|
111
|
+
remove()
|
|
112
|
+
log('cloud session cleared')
|
|
113
|
+
return { ok: true, ...status() }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function status () {
|
|
117
|
+
if (!session) return { loggedIn: false, upstream }
|
|
118
|
+
return {
|
|
119
|
+
loggedIn: true,
|
|
120
|
+
upstream,
|
|
121
|
+
slug: session.slug,
|
|
122
|
+
expiresAt: session.expiresAt || null,
|
|
123
|
+
expiresInDays: session.expiresAt ? Math.max(0, Math.round((session.expiresAt - Date.now()) / 86400000)) : null
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// One authenticated request against the cloud. Never throws: the caller is an HTTP
|
|
128
|
+
// handler and a boat is offline most of the time.
|
|
129
|
+
async function request (apiPath, { method = 'GET', body = null } = {}) {
|
|
130
|
+
if (!session) return { ok: false, status: 401, code: 'logged-out', message: 'not logged in to the cloud' }
|
|
131
|
+
let r
|
|
132
|
+
try {
|
|
133
|
+
r = await fetch(upstream + apiPath, {
|
|
134
|
+
method,
|
|
135
|
+
headers: {
|
|
136
|
+
Cookie: session.cookie,
|
|
137
|
+
Accept: 'application/json',
|
|
138
|
+
...(body ? { 'Content-Type': 'application/json' } : {})
|
|
139
|
+
},
|
|
140
|
+
body: body || undefined,
|
|
141
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
142
|
+
})
|
|
143
|
+
} catch (e) {
|
|
144
|
+
return { ok: false, status: 0, code: 'offline', message: `cannot reach the cloud (${e.message})` }
|
|
145
|
+
}
|
|
146
|
+
// A 401 means the session died (expired, or revoked by a logout elsewhere). Clear it
|
|
147
|
+
// so the page shows a login form instead of silently failing every button.
|
|
148
|
+
if (r.status === 401) {
|
|
149
|
+
session = null
|
|
150
|
+
remove()
|
|
151
|
+
warn('the cloud session is no longer valid — log in again on the Sync page')
|
|
152
|
+
return { ok: false, status: 401, code: 'logged-out', message: 'the cloud session expired — log in again' }
|
|
153
|
+
}
|
|
154
|
+
const text = await r.text().catch(() => '')
|
|
155
|
+
let json = null
|
|
156
|
+
try { json = JSON.parse(text) } catch {}
|
|
157
|
+
return { ok: r.ok, status: r.status, json, text }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---- HTTP surface, mounted on the plugin router --------------------------------
|
|
161
|
+
const send = (res, code, obj) => {
|
|
162
|
+
res.statusCode = code
|
|
163
|
+
res.setHeader('Content-Type', 'application/json')
|
|
164
|
+
res.end(JSON.stringify(obj))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function readJson (req) {
|
|
168
|
+
return new Promise((resolve) => {
|
|
169
|
+
let size = 0
|
|
170
|
+
const chunks = []
|
|
171
|
+
req.on('data', (c) => {
|
|
172
|
+
size += c.length
|
|
173
|
+
if (size > MAX_BODY_BYTES) { req.destroy(); resolve(undefined); return }
|
|
174
|
+
chunks.push(c)
|
|
175
|
+
})
|
|
176
|
+
req.on('error', () => resolve(undefined))
|
|
177
|
+
req.on('end', () => {
|
|
178
|
+
const raw = Buffer.concat(chunks).toString('utf8')
|
|
179
|
+
if (!raw.trim()) return resolve(null)
|
|
180
|
+
try { resolve(JSON.parse(raw)) } catch { resolve(undefined) }
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// `rest` is the path after /cloud — '', 'status', 'login', 'logout', 'profile/...'
|
|
186
|
+
async function handle (rest, req, res) {
|
|
187
|
+
const [head, ...tail] = String(rest || '').replace(/^\/+/, '').split('/')
|
|
188
|
+
const method = req.method === 'HEAD' ? 'GET' : req.method
|
|
189
|
+
|
|
190
|
+
if (head === 'status' && method === 'GET') return send(res, 200, { ok: true, ...status() })
|
|
191
|
+
|
|
192
|
+
if (head === 'logout' && method === 'POST') return send(res, 200, logout())
|
|
193
|
+
|
|
194
|
+
if (head === 'login' && method === 'POST') {
|
|
195
|
+
const body = await readJson(req)
|
|
196
|
+
if (!body || typeof body !== 'object') return send(res, 400, { ok: false, code: 'bad-body', message: 'send {slug, password}' })
|
|
197
|
+
const r = await login(String(body.slug || '').trim(), String(body.password || ''))
|
|
198
|
+
return send(res, r.ok ? 200 : (r.code === 'bad-credentials' ? 401 : 502), r)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Everything under /cloud/profile is relayed verbatim to the cloud's own profile API,
|
|
202
|
+
// so polars and routes need no per-section code here — the shapes already match the
|
|
203
|
+
// boat's local copy (lib/profile/index.js mirrors the same router).
|
|
204
|
+
if (head === 'profile') {
|
|
205
|
+
const body = (method === 'POST' || method === 'PUT') ? JSON.stringify(await readJson(req)) : null
|
|
206
|
+
const target = '/api/profile' + (tail.length ? '/' + tail.map(encodeURIComponent).join('/') : '')
|
|
207
|
+
const r = await request(target, { method, body })
|
|
208
|
+
if (!r.ok && r.code) return send(res, r.status === 401 ? 401 : 502, { ok: false, code: r.code, message: r.message })
|
|
209
|
+
res.statusCode = r.status
|
|
210
|
+
res.setHeader('Content-Type', 'application/json')
|
|
211
|
+
return res.end(r.text || '{}')
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
send(res, 404, { ok: false, code: 'not-found', message: `${req.method} /cloud/${rest} not found` })
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function start () {
|
|
218
|
+
load()
|
|
219
|
+
log(session ? `cloud session for "${session.slug}" loaded` : 'no cloud session — log in on the Sync page to copy polars and routes')
|
|
220
|
+
}
|
|
221
|
+
function stop () { session = null }
|
|
222
|
+
|
|
223
|
+
return { start, stop, handle, status, login, logout, request, _file: () => file }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = { createCloud, COOKIE_NAME }
|
package/lib/proxy/index.js
CHANGED
|
@@ -93,9 +93,11 @@ function createProxy (app, options) {
|
|
|
93
93
|
history: options.history || null,
|
|
94
94
|
aisTargets: options.aisTargets || null,
|
|
95
95
|
profile: options.profile || null,
|
|
96
|
+
boat: options.boat || null, // { perfKey, slug } — patched into /api/config, see serveConfig
|
|
96
97
|
openAccess: options.openAccess !== false
|
|
97
98
|
}
|
|
98
99
|
log(`mirroring ${cfg.upstream}; local SignalK ${cfg.localSignalk}; store ${cfg.storeDir}`)
|
|
100
|
+
if (cfg.boat && cfg.boat.perfKey) log(`boat identity for the app: perfKey=${cfg.boat.perfKey} (performance data cloud at /perf/${cfg.boat.perfKey}/)`)
|
|
99
101
|
|
|
100
102
|
// Cache-manifest poller: auto-refresh a dataset lazily when the cloud
|
|
101
103
|
// announces a new bake. Tiles are otherwise pinned (no time-based expiry).
|
|
@@ -293,6 +295,26 @@ function createProxy (app, options) {
|
|
|
293
295
|
if (j && typeof j === 'object') {
|
|
294
296
|
j.auth = { ...(j.auth || {}), required: false } // single-tenant boat: no cloud login
|
|
295
297
|
if (cfg.history && cfg.history.available()) j.historyAvailable = true // served locally (InfluxDB or ring)
|
|
298
|
+
// The cloud fills `boat` only for a logged-in session, and the mirror forwards no
|
|
299
|
+
// cookie — so it always arrived as null and the app had no identity. Harmless for
|
|
300
|
+
// most of the UI, but public/engine/polar-cloud.js keys the performance data cloud
|
|
301
|
+
// on boat.perfKey and throws 'no boat identity' without it, so the polar cloud was
|
|
302
|
+
// simply absent on the boat while the bake sat cached and reachable.
|
|
303
|
+
//
|
|
304
|
+
// perfKey is not guessed: server/auth/registry.js defaults `bucket` and `perfKey`
|
|
305
|
+
// from the same identity, so the bucket minus its _raw suffix IS the perf key —
|
|
306
|
+
// for UUID accounts and for grandfathered slug ones alike.
|
|
307
|
+
if (cfg.boat && cfg.boat.perfKey) {
|
|
308
|
+
j.boat = {
|
|
309
|
+
...(j.boat || {}),
|
|
310
|
+
perfKey: cfg.boat.perfKey,
|
|
311
|
+
slug: cfg.boat.slug || cfg.boat.perfKey,
|
|
312
|
+
name: (j.boat && j.boat.name) || cfg.boat.slug || cfg.boat.perfKey,
|
|
313
|
+
// NEVER readOnly: that flag exists for public visitor sessions, and it hides
|
|
314
|
+
// the editing affordances. The owner at the chart table has full control.
|
|
315
|
+
readOnly: false
|
|
316
|
+
}
|
|
317
|
+
}
|
|
296
318
|
out = Buffer.from(JSON.stringify(j))
|
|
297
319
|
ct = 'application/json'
|
|
298
320
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sailkick-boat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Run the sailkick app on board with no internet: charts, weather, climatology, trends and AIS all served from the boat itself. With a sailkick account it also syncs your metrics to the cloud in real time. Alpha, invite-only \u2014 info@sailkick.io",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
package/public/index.html
CHANGED
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
<div class="grid">
|
|
52
52
|
<a class="card" id="desktop" href="#"><strong>Open Sailkick</strong><span>Full app — chart, trends, weather</span></a>
|
|
53
53
|
<a class="card" id="mobile" href="#"><strong>Open on phone</strong><span>Mobile view — swipeable instrument decks</span></a>
|
|
54
|
+
<a class="card" href="sync.html"><strong>Sync polars & routes</strong><span>Copy between this boat and the cloud</span></a>
|
|
54
55
|
</div>
|
|
55
56
|
|
|
56
57
|
<div class="note" id="note">Locating the mirror…</div>
|
package/public/sync.html
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Sailkick · Sync</title>
|
|
7
|
+
<!--
|
|
8
|
+
Copy polars and routes between this boat and the cloud account.
|
|
9
|
+
|
|
10
|
+
Both sides are read through the PLUGIN ROUTER (/plugins/sailkick-boat/...), never the
|
|
11
|
+
mirror on :8080 — that router is behind Signal K's own security, and it keeps this page
|
|
12
|
+
same-origin with both sides so no CORS is involved.
|
|
13
|
+
|
|
14
|
+
Items are matched by NAME, not id: ids are assigned independently on each side, so the
|
|
15
|
+
same polar has two different ids. Nothing is ever overwritten without a second, explicit
|
|
16
|
+
click, and nothing is deleted at all.
|
|
17
|
+
-->
|
|
18
|
+
<style>
|
|
19
|
+
:root { color-scheme: light dark; --bg:#f6f7f9; --fg:#12161c; --mut:#5c6672; --card:#fff; --line:#e2e6eb;
|
|
20
|
+
--accent:#0b6ea9; --ok:#1d7a4c; --warn:#b4791f; --err:#b4453a }
|
|
21
|
+
@media (prefers-color-scheme: dark) {
|
|
22
|
+
:root { --bg:#11151a; --fg:#e8edf2; --mut:#96a1ad; --card:#171c23; --line:#252c35;
|
|
23
|
+
--accent:#4aa8e0; --ok:#4cc38a; --warn:#d9a441; --err:#e0736a }
|
|
24
|
+
}
|
|
25
|
+
* { box-sizing: border-box }
|
|
26
|
+
body { margin:0; padding:1.5rem 1rem 3rem; background:var(--bg); color:var(--fg);
|
|
27
|
+
font:15px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif }
|
|
28
|
+
main { max-width:60rem; margin:0 auto }
|
|
29
|
+
h1 { margin:0 0 .2rem; font-size:1.4rem; display:flex; align-items:center; gap:.5rem }
|
|
30
|
+
h1 img { border-radius:.35rem }
|
|
31
|
+
h2 { font-size:1rem; margin:1.6rem 0 .5rem; color:var(--mut); font-weight:600;
|
|
32
|
+
text-transform:uppercase; letter-spacing:.04em }
|
|
33
|
+
.sub { color:var(--mut); margin:0 0 1.2rem }
|
|
34
|
+
.bar { display:flex; flex-wrap:wrap; gap:.6rem; align-items:center; padding:.8rem 1rem; background:var(--card);
|
|
35
|
+
border:1px solid var(--line); border-radius:.6rem; margin-bottom:1rem }
|
|
36
|
+
.bar .grow { flex:1 1 auto }
|
|
37
|
+
input { padding:.45rem .6rem; border:1px solid var(--line); border-radius:.4rem;
|
|
38
|
+
background:var(--bg); color:var(--fg); font:inherit; min-width:9rem }
|
|
39
|
+
button { padding:.45rem .8rem; border:1px solid var(--line); border-radius:.4rem; background:var(--card);
|
|
40
|
+
color:inherit; font:inherit; cursor:pointer }
|
|
41
|
+
button:hover:not(:disabled) { border-color:var(--accent) }
|
|
42
|
+
button:disabled { opacity:.4; cursor:default }
|
|
43
|
+
button.primary { background:var(--accent); border-color:var(--accent); color:#fff }
|
|
44
|
+
table { width:100%; border-collapse:collapse; background:var(--card);
|
|
45
|
+
border:1px solid var(--line); border-radius:.6rem; overflow:hidden }
|
|
46
|
+
th, td { padding:.55rem .7rem; text-align:left; border-bottom:1px solid var(--line); vertical-align:middle }
|
|
47
|
+
th { font-size:.78rem; text-transform:uppercase; letter-spacing:.04em; color:var(--mut) }
|
|
48
|
+
tr:last-child td { border-bottom:none }
|
|
49
|
+
td.act { text-align:right; white-space:nowrap }
|
|
50
|
+
.tag { font-size:.75rem; padding:.1rem .45rem; border-radius:.3rem; border:1px solid var(--line); color:var(--mut) }
|
|
51
|
+
.tag.ok { color:var(--ok); border-color:var(--ok) }
|
|
52
|
+
.tag.diff { color:var(--warn); border-color:var(--warn) }
|
|
53
|
+
.tag.only { color:var(--accent); border-color:var(--accent) }
|
|
54
|
+
.msg { padding:.7rem 1rem; border-radius:.5rem; border:1px solid var(--line); background:var(--card); margin:.8rem 0 }
|
|
55
|
+
.msg.err { border-color:var(--err); color:var(--err) }
|
|
56
|
+
.msg.ok { border-color:var(--ok) }
|
|
57
|
+
.empty { color:var(--mut); padding:.8rem .7rem }
|
|
58
|
+
code { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:.85em }
|
|
59
|
+
</style>
|
|
60
|
+
</head>
|
|
61
|
+
<body>
|
|
62
|
+
<main>
|
|
63
|
+
<h1><img src="icon.png" alt="" width="26" height="26">Sync polars & routes</h1>
|
|
64
|
+
<p class="sub">Copy between this boat and your sailkick cloud account. Nothing is deleted, and nothing is
|
|
65
|
+
overwritten without asking.</p>
|
|
66
|
+
|
|
67
|
+
<div class="bar" id="authbar"><span class="grow">Checking the cloud session…</span></div>
|
|
68
|
+
<div id="msg"></div>
|
|
69
|
+
|
|
70
|
+
<h2>Polars</h2>
|
|
71
|
+
<div id="polars"></div>
|
|
72
|
+
<h2>Routes</h2>
|
|
73
|
+
<div id="routes"></div>
|
|
74
|
+
</main>
|
|
75
|
+
|
|
76
|
+
<script>
|
|
77
|
+
(function () {
|
|
78
|
+
var API = '/plugins/sailkick-boat';
|
|
79
|
+
var $ = function (id) { return document.getElementById(id); };
|
|
80
|
+
var esc = function (s) { var d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; };
|
|
81
|
+
var state = { loggedIn: false, boat: {}, cloud: {} };
|
|
82
|
+
|
|
83
|
+
function say (text, kind) {
|
|
84
|
+
$('msg').innerHTML = text ? '<div class="msg ' + (kind || '') + '">' + esc(text) + '</div>' : '';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function j (path, opts) {
|
|
88
|
+
return fetch(API + path, Object.assign({ credentials: 'same-origin' }, opts || {}))
|
|
89
|
+
.then(function (r) { return r.json().catch(function () { return {}; })
|
|
90
|
+
.then(function (b) { return { status: r.status, body: b }; }); });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---- identity ---------------------------------------------------------------
|
|
94
|
+
// Ids are per-side, so a polar present in both has two different ones. Name is the
|
|
95
|
+
// only stable handle across the boundary.
|
|
96
|
+
function key (item) { return String(item && item.name || '').trim().toLowerCase(); }
|
|
97
|
+
|
|
98
|
+
// Same name — same thing? Compare what the user actually authored, not the wrapper:
|
|
99
|
+
// the CSV for a polar, the geometry for a route. updatedAt and id always differ.
|
|
100
|
+
function same (a, b, section) {
|
|
101
|
+
if (section === 'polars') return String(a.csv || '') === String(b.csv || '');
|
|
102
|
+
return JSON.stringify(a.path || null) === JSON.stringify(b.path || null) &&
|
|
103
|
+
JSON.stringify(a.destination || null) === JSON.stringify(b.destination || null);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function rows (section) {
|
|
107
|
+
var boat = state.boat[section] || [], cloud = state.cloud[section] || [];
|
|
108
|
+
var names = {}, out = [];
|
|
109
|
+
boat.forEach(function (i) { names[key(i)] = { name: i.name, boat: i }; });
|
|
110
|
+
cloud.forEach(function (i) { (names[key(i)] = names[key(i)] || { name: i.name }).cloud = i; });
|
|
111
|
+
Object.keys(names).sort().forEach(function (k) { out.push(names[k]); });
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function render (section, el) {
|
|
116
|
+
var list = rows(section);
|
|
117
|
+
if (!list.length) {
|
|
118
|
+
el.innerHTML = '<div class="msg"><span class="empty">Nothing on either side yet.</span></div>';
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
var html = '<table><tr><th>Name</th><th>On the boat</th><th>In the cloud</th><th></th></tr>';
|
|
122
|
+
list.forEach(function (r, i) {
|
|
123
|
+
var tag, act = '';
|
|
124
|
+
if (r.boat && r.cloud) {
|
|
125
|
+
var eq = same(r.boat, r.cloud, section);
|
|
126
|
+
tag = eq ? '<span class="tag ok">in sync</span>' : '<span class="tag diff">differs</span>';
|
|
127
|
+
if (!eq) {
|
|
128
|
+
act = '<button data-a="pull" data-s="' + section + '" data-i="' + i + '">⤓ cloud → boat</button> ' +
|
|
129
|
+
'<button data-a="push" data-s="' + section + '" data-i="' + i + '">⤒ boat → cloud</button>';
|
|
130
|
+
}
|
|
131
|
+
} else if (r.cloud) {
|
|
132
|
+
tag = '<span class="tag only">cloud only</span>';
|
|
133
|
+
act = '<button class="primary" data-a="pull" data-s="' + section + '" data-i="' + i + '">⤓ copy to boat</button>';
|
|
134
|
+
} else {
|
|
135
|
+
tag = '<span class="tag only">boat only</span>';
|
|
136
|
+
act = '<button data-a="push" data-s="' + section + '" data-i="' + i + '">⤒ copy to cloud</button>';
|
|
137
|
+
}
|
|
138
|
+
html += '<tr><td>' + esc(r.name) + ' ' + tag + '</td>' +
|
|
139
|
+
'<td>' + (r.boat ? '✓' : '—') + '</td>' +
|
|
140
|
+
'<td>' + (r.cloud ? '✓' : '—') + '</td>' +
|
|
141
|
+
'<td class="act">' + (state.loggedIn ? act : '') + '</td></tr>';
|
|
142
|
+
});
|
|
143
|
+
el.innerHTML = html + '</table>';
|
|
144
|
+
el.querySelectorAll('button[data-a]').forEach(function (b) {
|
|
145
|
+
b.addEventListener('click', function () { copy(b.dataset.a, b.dataset.s, Number(b.dataset.i), b); });
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---- copy one item ----------------------------------------------------------
|
|
150
|
+
function copy (dir, section, idx, btn) {
|
|
151
|
+
var r = rows(section)[idx];
|
|
152
|
+
var from = dir === 'pull' ? r.cloud : r.boat;
|
|
153
|
+
var onto = dir === 'pull' ? r.boat : r.cloud;
|
|
154
|
+
if (!from) return;
|
|
155
|
+
if (onto && !confirm('"' + r.name + '" already exists on the ' + (dir === 'pull' ? 'boat' : 'cloud') +
|
|
156
|
+
' and differs.\n\nOverwrite it with the ' + (dir === 'pull' ? 'cloud' : 'boat') + ' copy?')) return;
|
|
157
|
+
|
|
158
|
+
// Carry only the authored fields — never the source id or updatedAt, which belong to
|
|
159
|
+
// the side they came from.
|
|
160
|
+
var body = {};
|
|
161
|
+
Object.keys(from).forEach(function (k) { if (k !== 'id' && k !== 'updatedAt') body[k] = from[k]; });
|
|
162
|
+
|
|
163
|
+
var base = dir === 'pull' ? '/profile/' + section : '/cloud/profile/' + section;
|
|
164
|
+
var path = onto ? base + '/' + encodeURIComponent(onto.id) : base;
|
|
165
|
+
btn.disabled = true;
|
|
166
|
+
say('Copying "' + r.name + '"…');
|
|
167
|
+
j(path, { method: onto ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
|
168
|
+
.then(function (res) {
|
|
169
|
+
if (res.status >= 200 && res.status < 300) { say('Copied "' + r.name + '".', 'ok'); return load(); }
|
|
170
|
+
say('Could not copy "' + r.name + '": ' + (res.body.message || 'HTTP ' + res.status), 'err');
|
|
171
|
+
btn.disabled = false;
|
|
172
|
+
})
|
|
173
|
+
.catch(function (e) { say('Could not copy: ' + e.message, 'err'); btn.disabled = false; });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- load both sides --------------------------------------------------------
|
|
177
|
+
function load () {
|
|
178
|
+
return j('/cloud/status').then(function (s) {
|
|
179
|
+
state.loggedIn = !!(s.body && s.body.loggedIn);
|
|
180
|
+
authbar(s.body || {});
|
|
181
|
+
// Both sides use the same section endpoints, so the response shape is identical:
|
|
182
|
+
// { ok, polars: [...] } / { ok, routes: [...] }.
|
|
183
|
+
var want = [j('/profile/polars'), j('/profile/routes')];
|
|
184
|
+
if (state.loggedIn) want.push(j('/cloud/profile/polars'), j('/cloud/profile/routes'));
|
|
185
|
+
return Promise.all(want).then(function (r) {
|
|
186
|
+
state.boat.polars = (r[0].body && r[0].body.polars) || [];
|
|
187
|
+
state.boat.routes = (r[1].body && r[1].body.routes) || [];
|
|
188
|
+
state.cloud.polars = state.loggedIn ? ((r[2].body && r[2].body.polars) || []) : [];
|
|
189
|
+
state.cloud.routes = state.loggedIn ? ((r[3].body && r[3].body.routes) || []) : [];
|
|
190
|
+
if (state.loggedIn && r[2].status === 401) { state.loggedIn = false; authbar({ loggedIn: false }); }
|
|
191
|
+
render('polars', $('polars'));
|
|
192
|
+
render('routes', $('routes'));
|
|
193
|
+
});
|
|
194
|
+
}).catch(function (e) { say('Cannot reach the plugin: ' + e.message, 'err'); });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function authbar (s) {
|
|
198
|
+
var bar = $('authbar');
|
|
199
|
+
if (s.loggedIn) {
|
|
200
|
+
bar.innerHTML = '<span class="grow">Signed in to the cloud as <b>' + esc(s.slug) + '</b>' +
|
|
201
|
+
(s.expiresInDays != null ? ' · session expires in ' + s.expiresInDays + ' days' : '') + '</span>' +
|
|
202
|
+
'<button id="out">Sign out</button>';
|
|
203
|
+
$('out').addEventListener('click', function () {
|
|
204
|
+
j('/cloud/logout', { method: 'POST' }).then(function () { say(''); load(); });
|
|
205
|
+
});
|
|
206
|
+
} else {
|
|
207
|
+
bar.innerHTML = '<span class="grow">Sign in to your sailkick account to see the cloud side.</span>' +
|
|
208
|
+
'<input id="slug" placeholder="boat name" autocomplete="username">' +
|
|
209
|
+
'<input id="pw" type="password" placeholder="password" autocomplete="current-password">' +
|
|
210
|
+
'<button class="primary" id="in">Sign in</button>';
|
|
211
|
+
var go = function () {
|
|
212
|
+
var slug = $('slug').value.trim(), pw = $('pw').value;
|
|
213
|
+
if (!slug || !pw) return;
|
|
214
|
+
$('in').disabled = true; say('Signing in…');
|
|
215
|
+
j('/cloud/login', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
216
|
+
body: JSON.stringify({ slug: slug, password: pw }) })
|
|
217
|
+
.then(function (res) {
|
|
218
|
+
if (res.body && res.body.loggedIn) { say('Signed in. Your password is not stored — only the session.', 'ok'); return load(); }
|
|
219
|
+
say((res.body && res.body.message) || 'Sign in failed', 'err');
|
|
220
|
+
$('in').disabled = false;
|
|
221
|
+
})
|
|
222
|
+
.catch(function (e) { say('Sign in failed: ' + e.message, 'err'); $('in').disabled = false; });
|
|
223
|
+
};
|
|
224
|
+
$('in').addEventListener('click', go);
|
|
225
|
+
$('pw').addEventListener('keydown', function (e) { if (e.key === 'Enter') go(); });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
load();
|
|
230
|
+
})();
|
|
231
|
+
</script>
|
|
232
|
+
</body>
|
|
233
|
+
</html>
|