tosijs-ui 1.16.34 → 1.16.36
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 +79 -0
- package/bin/caddy-install.ts +73 -13
- package/bin/tunnel-links.ts +208 -0
- package/bin/tunnel.ts +37 -142
- package/dist/carousel.d.ts +1 -0
- package/dist/carousel.js +8 -4
- package/dist/crud.js +18 -2
- package/dist/data-table.d.ts +1 -0
- package/dist/data-table.js +10 -3
- package/dist/doc-browser.d.ts +5 -0
- package/dist/doc-browser.js +9 -7
- package/dist/doc-system/doc-system.d.ts +2 -0
- package/dist/doc-system/doc-system.js +11 -0
- package/dist/doc-system/routing.d.ts +14 -1
- package/dist/doc-system/routing.js +38 -5
- package/dist/doc-system/site/dev-auth.d.ts +48 -9
- package/dist/doc-system/site/dev-auth.js +120 -31
- package/dist/doc-system/site/dev-server.js +93 -23
- package/dist/doc-system/site/device-revocation.d.ts +18 -0
- package/dist/doc-system/site/device-revocation.js +34 -1
- package/dist/doc-system/site/generate-site.d.ts +8 -0
- package/dist/doc-system/site/generate-site.js +57 -4
- package/dist/doc-system/site/invite-page.js +8 -7
- package/dist/doc-system/site/orchestrator.js +1 -0
- package/dist/doc-system/site/session-secret.d.ts +16 -0
- package/dist/doc-system/site/session-secret.js +84 -0
- package/dist/doc-system/site/site-config.d.ts +10 -0
- package/dist/iife.js +111 -111
- package/dist/iife.js.map +11 -11
- package/dist/palette.d.ts +7 -1
- package/dist/palette.js +44 -9
- package/dist/side-nav.js +3 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/llms.txt +1 -1
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,84 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.16.36
|
|
4
|
+
|
|
5
|
+
One additive site option. Nothing changes for a site that does not set it.
|
|
6
|
+
|
|
7
|
+
- **A page can be served at a second path (#3513).** `aliases: { readme: '' }` in the site
|
|
8
|
+
config also writes the root page to `/readme/`, with its stylesheet, bundle and `docs.json`
|
|
9
|
+
references worked out for that depth, and the browser opens it as the README. Before this a
|
|
10
|
+
host rewrite was the only way, and it served the page one level down with every relative
|
|
11
|
+
reference 404ing, so the page rendered but never hydrated. An alias page's canonical link
|
|
12
|
+
names the page's own path, aliases stay out of the sitemap and the nav, and relative images
|
|
13
|
+
and links in the page are rewritten to reach the same files. An alias is one lowercase path
|
|
14
|
+
segment with no dot; the build fails on one that is not, that takes a page's own path or a
|
|
15
|
+
static folder's, or that names no page.
|
|
16
|
+
|
|
17
|
+
## 1.16.35
|
|
18
|
+
|
|
19
|
+
Board follow-ups from 1.16.32 and 1.16.33, plus one tunnel feature. Nothing to change in an
|
|
20
|
+
adopter's product. **Tunnel users:** a plain `--link` / `--share` now keeps its browser in
|
|
21
|
+
across a dev-server restart for 24 hours, and the dev server writes a signing secret to
|
|
22
|
+
`.tosijs-dev/` in your project (it gitignores itself). **Box operators:** re-run
|
|
23
|
+
`tosijs-caddy-install --go` to tighten the access-log permissions; the dry run no longer
|
|
24
|
+
prints old keys.
|
|
25
|
+
|
|
26
|
+
### Tunnel
|
|
27
|
+
|
|
28
|
+
- **A link survives a restart (#3416).** Redeeming a plain `--link` or `--share` link also
|
|
29
|
+
sets a signed pass (`__Host-tosi_link_pass`: kind, workspace host, expiry 24 hours after
|
|
30
|
+
redemption, HMAC-SHA256 with a per-project secret). After a restart, the next page load or
|
|
31
|
+
same-origin request trades it for a fresh session of the same kind, the way a `--global`
|
|
32
|
+
key is traded. A share link's pass stays read-only, a pass works only on the host it was
|
|
33
|
+
issued for, and a session it mints never outlives it.
|
|
34
|
+
- **What that costs.** Until now nothing about a link session outlived the process, so a
|
|
35
|
+
stolen session cookie died with the server. A stolen pass now works for the rest of its 24
|
|
36
|
+
hours, across restarts. To revoke every pass at once, delete
|
|
37
|
+
`.tosijs-dev/session-secret`. The secret is created 0600 in a 0700 folder. The dev server
|
|
38
|
+
refuses to start if it sits inside the output folder; if group or others have any access to
|
|
39
|
+
it, links still open but stop surviving restarts, and the console says why. A filesystem that keeps no modes (a Windows
|
|
40
|
+
drive under WSL, FAT) cannot hold the secret: plain links there end on restart, as before.
|
|
41
|
+
Revoking a device does not end a link pass.
|
|
42
|
+
- **The "dev server restarted" page** now says a link keeps its browser in for 24 hours,
|
|
43
|
+
instead of claiming sessions are never written to disk.
|
|
44
|
+
|
|
45
|
+
### Preview box
|
|
46
|
+
|
|
47
|
+
- **The dry run no longer prints old keys (#3502).** `tosijs-caddy-install` redacted only the
|
|
48
|
+
current keys from preview.env, but the diff's removed lines come from the live Caddyfile, so
|
|
49
|
+
after a rotation the previous `PREVIEW_TOKEN` / `PREVIEW_EDIT_TOKEN` printed in clear. Every
|
|
50
|
+
value in a key position (`tosi_preview=`, `tosi_edit=`, `query t=`) on either side is now
|
|
51
|
+
blanked wherever it appears, comments included, and so is the refusal that names leftover
|
|
52
|
+
placeholders.
|
|
53
|
+
- **Access logs.** `--go` takes group and other access off the existing preview access logs
|
|
54
|
+
(`/var/log/caddy/preview*.log*`, symlinks skipped), which record short-link codes. A log
|
|
55
|
+
shipper that reads them through group membership loses access; give it root or the caddy
|
|
56
|
+
user. Caddy 2.6 has no log mode, so a log Caddy creates after the install gets Caddy's own
|
|
57
|
+
mode; doc-site-system.md says exactly what is covered.
|
|
58
|
+
- **Paths with a space.** The installer found neither its template nor the gate page when the
|
|
59
|
+
package lived under a path with a space (`new URL().pathname` kept `%20`).
|
|
60
|
+
- **Gate page.** The installer reads `deploy/gate-page.html` only when the template names
|
|
61
|
+
`__GATE_PAGE__`, and refuses a missing one with a message instead of a stack trace.
|
|
62
|
+
- **Custom templates.** doc-site-system.md now says a custom `--template` keeps its old gate
|
|
63
|
+
page and never serves short links, and lists the lines to copy from `deploy/Caddyfile`.
|
|
64
|
+
|
|
65
|
+
### Components
|
|
66
|
+
|
|
67
|
+
- **`<tosi-table>` with no data.** `columns` returned a new `[]` on every read, and
|
|
68
|
+
`table.value = { ...table.value, array }` stored that `[]` as the columns, fixing the table
|
|
69
|
+
at zero columns for the data that followed. The empty list is now one per table, and handing
|
|
70
|
+
it back means "infer".
|
|
71
|
+
- **`<tosi-palette>`.** An unknown `trigger` value now behaves exactly as `field` (before, it
|
|
72
|
+
showed the full field but never compacted), and warns once per value, naming it and the
|
|
73
|
+
fallback. Setting `value` from code was ignored once
|
|
74
|
+
the query field existed; it now updates the field, and searches again if the palette is
|
|
75
|
+
open.
|
|
76
|
+
- **Doc browser.** Choosing a palette section result while the source editor held unsaved
|
|
77
|
+
edits, and dismissing the confirm, still rewrote the URL to the target's `#anchor` and
|
|
78
|
+
scrolled. Now nothing moves when navigation is refused.
|
|
79
|
+
- **`<tosi-crud>` docs** describe `close()`, the Close button, the one-column narrow layout and
|
|
80
|
+
`TosiCrud.NARROW_WIDTH`.
|
|
81
|
+
|
|
3
82
|
## 1.16.34
|
|
4
83
|
|
|
5
84
|
Spinning icons keep spinning while the page is busy, which is the only time a loader is on
|
package/bin/caddy-install.ts
CHANGED
|
@@ -36,6 +36,7 @@ Three deliberate properties:
|
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
38
|
import { existsSync, readFileSync } from 'fs'
|
|
39
|
+
import { fileURLToPath } from 'url'
|
|
39
40
|
import {
|
|
40
41
|
embedGatePage,
|
|
41
42
|
parseArgv,
|
|
@@ -82,8 +83,14 @@ The template ships inside the package, so the default is resolved relative to TH
|
|
|
82
83
|
rather than to the cwd — an adopter runs the bin from their own project, where `deploy/` is
|
|
83
84
|
their business and not ours.
|
|
84
85
|
*/
|
|
86
|
+
/*
|
|
87
|
+
`fileURLToPath`, not `new URL(...).pathname`: the latter keeps percent-encoding, so a package
|
|
88
|
+
installed under a path with a space (`/Users/me/My Projects/…`) resolved to `My%20Projects` and
|
|
89
|
+
the shipped template was reported missing.
|
|
90
|
+
*/
|
|
85
91
|
const templatePath =
|
|
86
|
-
flag('template') ??
|
|
92
|
+
flag('template') ??
|
|
93
|
+
fileURLToPath(new URL('../deploy/Caddyfile', import.meta.url))
|
|
87
94
|
|
|
88
95
|
if (!existsSync(templatePath)) {
|
|
89
96
|
console.error(
|
|
@@ -97,15 +104,26 @@ if (!existsSync(templatePath)) {
|
|
|
97
104
|
/*
|
|
98
105
|
`__GATE_PAGE__` is the page a browser without the preview cookie sees (deploy/gate-page.html,
|
|
99
106
|
with its box for a link or a code). It is not a secret, so it is embedded here rather than on the
|
|
100
|
-
box. A template of your own that names it gets the shipped page
|
|
107
|
+
box. A template of your own that names it gets the shipped page; one that does not never needs
|
|
108
|
+
the file, so it is read only when named.
|
|
101
109
|
*/
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
'
|
|
110
|
+
const rawTemplate = readFileSync(templatePath, 'utf8')
|
|
111
|
+
let template = rawTemplate
|
|
112
|
+
if (rawTemplate.includes('__GATE_PAGE__')) {
|
|
113
|
+
const gatePagePath = fileURLToPath(
|
|
114
|
+
new URL('../deploy/gate-page.html', import.meta.url)
|
|
107
115
|
)
|
|
108
|
-
)
|
|
116
|
+
if (!existsSync(gatePagePath)) {
|
|
117
|
+
console.error(
|
|
118
|
+
`\nNo gate page at ${gatePagePath}\n\n` +
|
|
119
|
+
` ${templatePath} names __GATE_PAGE__, which is filled from that file.\n` +
|
|
120
|
+
` The package ships it at node_modules/tosijs-ui/deploy/gate-page.html;\n` +
|
|
121
|
+
` reinstall the package, or drop __GATE_PAGE__ from your template.\n`
|
|
122
|
+
)
|
|
123
|
+
process.exit(1)
|
|
124
|
+
}
|
|
125
|
+
template = embedGatePage(rawTemplate, readFileSync(gatePagePath, 'utf8'))
|
|
126
|
+
}
|
|
109
127
|
|
|
110
128
|
// The template is delivered inside a quoted heredoc, so it is copied verbatim with no shell
|
|
111
129
|
// expansion. A template containing the delimiter would truncate the file silently — refuse
|
|
@@ -154,6 +172,40 @@ elif [ "$PREVIEW_EDIT_TOKEN" = "$PREVIEW_TOKEN" ]; then
|
|
|
154
172
|
exit 1
|
|
155
173
|
fi
|
|
156
174
|
|
|
175
|
+
# Everything this script prints from a Caddyfile goes through \`redact <files>\`. Redacting only
|
|
176
|
+
# the CURRENT keys was not enough: a dry-run diff's removed lines come from the live file, which
|
|
177
|
+
# after a rotation holds the PREVIOUS keys (board #3502). So the values are harvested from every
|
|
178
|
+
# position the template puts a key, in the files being shown: the cookie names in matchers and
|
|
179
|
+
# Set-Cookie (\`tosi_preview=\`, \`tosi_edit=\`) and the link matchers (\`query t=\`). Each value,
|
|
180
|
+
# and each current key, is then blanked wherever it appears, comments included (the installer's
|
|
181
|
+
# substitution reaches the comments that name the placeholders), longest first so one key cannot
|
|
182
|
+
# leave part of another showing. Finally anything still in a key position is blanked by shape.
|
|
183
|
+
# Here the values travel in the environment, not argv, where \`ps\` would show them. (The
|
|
184
|
+
# install's own sed below still passes the keys in argv, for the moment it runs.)
|
|
185
|
+
KEY_POSITION='(tosi_(preview|edit)=|query[[:space:]]+t=)'
|
|
186
|
+
KEY_VALUE='[^[:space:];"()|*]+'
|
|
187
|
+
key_values() {
|
|
188
|
+
{ grep -ohE "$KEY_POSITION$KEY_VALUE" "$@" || [ $? -eq 1 ]; } | sed -E "s/^$KEY_POSITION//"
|
|
189
|
+
printf '%s\\n' "$PREVIEW_TOKEN" "$PREVIEW_EDIT_TOKEN"
|
|
190
|
+
}
|
|
191
|
+
redact() {
|
|
192
|
+
REDACT_KEYS="$(key_values "$@" | sort -u | awk 'length { print length, $0 }' | sort -rn | cut -d' ' -f2-)" \\
|
|
193
|
+
awk 'BEGIN { n = split(ENVIRON["REDACT_KEYS"], key, "\\n") }
|
|
194
|
+
{
|
|
195
|
+
line = $0
|
|
196
|
+
for (i = 1; i <= n; i++) {
|
|
197
|
+
out = ""
|
|
198
|
+
while (key[i] != "" && (at = index(line, key[i])) > 0) {
|
|
199
|
+
out = out substr(line, 1, at - 1) "***REDACTED***"
|
|
200
|
+
line = substr(line, at + length(key[i]))
|
|
201
|
+
}
|
|
202
|
+
line = out line
|
|
203
|
+
}
|
|
204
|
+
print line
|
|
205
|
+
}' \\
|
|
206
|
+
| sed -E "s/$KEY_POSITION$KEY_VALUE/\\1***REDACTED***/g"
|
|
207
|
+
}
|
|
208
|
+
|
|
157
209
|
sed -e "s|__PREVIEW_TOKEN__|$PREVIEW_TOKEN|g" \\
|
|
158
210
|
-e "s|__PREVIEW_EDIT_TOKEN__|$PREVIEW_EDIT_TOKEN|g" \\
|
|
159
211
|
-e "s|{{ACME_EMAIL}}|$ACME_EMAIL|g" \\
|
|
@@ -162,7 +214,7 @@ sed -e "s|__PREVIEW_TOKEN__|$PREVIEW_TOKEN|g" \\
|
|
|
162
214
|
|
|
163
215
|
if grep -qE '\\{\\{|__PREVIEW_(EDIT_)?TOKEN__' /etc/caddy/Caddyfile.new; then
|
|
164
216
|
echo "Unsubstituted placeholders remain — refusing to install:" >&2
|
|
165
|
-
grep -nE '\\{\\{|__PREVIEW_(EDIT_)?TOKEN__' /etc/caddy/Caddyfile.new >&2
|
|
217
|
+
grep -nE '\\{\\{|__PREVIEW_(EDIT_)?TOKEN__' /etc/caddy/Caddyfile.new | redact /etc/caddy/Caddyfile.new >&2
|
|
166
218
|
rm -f /etc/caddy/Caddyfile.new
|
|
167
219
|
exit 1
|
|
168
220
|
fi
|
|
@@ -195,6 +247,14 @@ if [ "${go ? '1' : '0'}" = "1" ]; then
|
|
|
195
247
|
else
|
|
196
248
|
chmod 600 /etc/caddy/Caddyfile.new
|
|
197
249
|
fi
|
|
250
|
+
# The access log records short-link codes as paths, unredacted (a code is spent or expired within
|
|
251
|
+
# five minutes). Caddy 2.6 has no log \`mode\`, so take group and other access off the preview logs
|
|
252
|
+
# that exist; Caddy keeps owning them. \`-type f\` skips symlinks, so a link planted in a directory
|
|
253
|
+
# Caddy can write cannot point this chmod at another file. Done before the swap, so a failure
|
|
254
|
+
# here leaves the running config untouched, like every other refusal.
|
|
255
|
+
if [ -d /var/log/caddy ]; then
|
|
256
|
+
find /var/log/caddy -maxdepth 1 -type f -name 'preview*.log*' -exec chmod go-rwx {} +
|
|
257
|
+
fi
|
|
198
258
|
mv /etc/caddy/Caddyfile.new /etc/caddy/Caddyfile
|
|
199
259
|
# A stopped Caddy cannot reload. Start it instead, or a box left down by the bug above could
|
|
200
260
|
# never be repaired by re-running this.
|
|
@@ -222,11 +282,11 @@ if [ "${go ? '1' : '0'}" = "1" ]; then
|
|
|
222
282
|
echo "Installed /etc/caddy/Caddyfile (caddy: $verb)."
|
|
223
283
|
echo "Previous config saved as /etc/caddy/Caddyfile.bak"
|
|
224
284
|
else
|
|
225
|
-
echo "--- would change /etc/caddy/Caddyfile (
|
|
285
|
+
echo "--- would change /etc/caddy/Caddyfile (keys redacted) ---"
|
|
226
286
|
if [ -f /etc/caddy/Caddyfile ]; then
|
|
227
|
-
diff
|
|
228
|
-
|
|
229
|
-
|
|
287
|
+
# diff exits 1 when the files differ, which is the expected case; 2 is a real failure.
|
|
288
|
+
{ diff -u /etc/caddy/Caddyfile /etc/caddy/Caddyfile.new || [ $? -eq 1 ]; } \\
|
|
289
|
+
| redact /etc/caddy/Caddyfile /etc/caddy/Caddyfile.new
|
|
230
290
|
else
|
|
231
291
|
echo "(no existing /etc/caddy/Caddyfile — this would create it)"
|
|
232
292
|
fi
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The links `tosijs-tunnel` prints: single-use global links (`--link --global`, and the ones
|
|
3
|
+
printed at tunnel start), the device record behind each edit pass, and the short link the box
|
|
4
|
+
can answer for a code.
|
|
5
|
+
|
|
6
|
+
Split out of bin/tunnel.ts so it can be tested without a box (board #3416 item 2, #3506): the
|
|
7
|
+
bin is a script that runs on import, so everything that reaches the network is passed in here
|
|
8
|
+
(`ssh`, `fetchText`, `askForLink`) and the bin wires the real ones.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { $ } from 'bun'
|
|
12
|
+
import {
|
|
13
|
+
deviceRegistryLine,
|
|
14
|
+
groupCode,
|
|
15
|
+
planGlobalLinks,
|
|
16
|
+
shortCodeScript,
|
|
17
|
+
type BoxKeys,
|
|
18
|
+
} from './resolve-site-config.ts'
|
|
19
|
+
import type { GlobalKeys } from '../dist/doc-system/site/dev-auth.js'
|
|
20
|
+
|
|
21
|
+
export type LinkKind = 'edit' | 'view'
|
|
22
|
+
|
|
23
|
+
export interface DevLinkAnswer {
|
|
24
|
+
url?: string
|
|
25
|
+
device?: string
|
|
26
|
+
error?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface GlobalLink {
|
|
30
|
+
kind: LinkKind
|
|
31
|
+
url: string
|
|
32
|
+
short?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface TunnelLinkDeps {
|
|
36
|
+
/** The preview box, as ssh names it. */
|
|
37
|
+
host: string
|
|
38
|
+
/** The preview root on the box (`/srv/preview`), where `_devices/` and `_codes/` live. */
|
|
39
|
+
root: string
|
|
40
|
+
projectName: string
|
|
41
|
+
/** The dev server's TLS port, for messages. */
|
|
42
|
+
localPort: number
|
|
43
|
+
/** `--label=`, naming the device a `--global` edit link is for. */
|
|
44
|
+
label?: string
|
|
45
|
+
/** True for `--link`; false for the links printed at tunnel start. */
|
|
46
|
+
onRequest: boolean
|
|
47
|
+
/** This machine's global keys, as `resolveGlobalKeys` read them. */
|
|
48
|
+
local: { keys: GlobalKeys; problems: string[] }
|
|
49
|
+
/** The box's domain and key fingerprints (`previewBoxKeys`). */
|
|
50
|
+
boxKeys: () => Promise<BoxKeys>
|
|
51
|
+
/** Run one command on the box; only the exit code is used. */
|
|
52
|
+
ssh: (command: string) => Promise<{ exitCode: number }>
|
|
53
|
+
/** Ask the dev server (loopback only) for a link; `query` selects the kind. */
|
|
54
|
+
askForLink: (query: string) => Promise<DevLinkAnswer>
|
|
55
|
+
/** GET a URL and return its body, or '' for anything but a 2xx. Never throws. */
|
|
56
|
+
fetchText: (url: string) => Promise<string>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/*
|
|
60
|
+
SHORT LINKS (board #3504): record which workspace a code belongs to, so `https://<domain>/<CODE>`
|
|
61
|
+
(or the code typed into the box on the gate and root pages) finds it. See shortCodeScript.
|
|
62
|
+
Returns the short link, or the reason there is none (the full link still works either way).
|
|
63
|
+
*/
|
|
64
|
+
export async function registerShortCode(
|
|
65
|
+
deps: Pick<TunnelLinkDeps, 'host' | 'root' | 'ssh' | 'fetchText'>,
|
|
66
|
+
url: string,
|
|
67
|
+
domain: string
|
|
68
|
+
): Promise<{ short?: string; problem?: string }> {
|
|
69
|
+
const code = new URL(url).searchParams.get('t') ?? ''
|
|
70
|
+
const script = shortCodeScript(deps.root, code, url)
|
|
71
|
+
if (!script) return { problem: `${url} is not a link the box can shorten` }
|
|
72
|
+
const run = await deps.ssh(script)
|
|
73
|
+
if (run.exitCode !== 0)
|
|
74
|
+
return { problem: `could not record it on ${deps.host}` }
|
|
75
|
+
// Read it back through Caddy: a box whose Caddyfile predates short links answers with its
|
|
76
|
+
// gate, and a short link printed over that would lead nowhere.
|
|
77
|
+
const served = await deps.fetchText(`https://${domain}/_codes/${code}`)
|
|
78
|
+
if (served.trim() !== url)
|
|
79
|
+
return {
|
|
80
|
+
problem: `the box's Caddyfile does not serve short links yet: run \`tosijs-caddy-install --go\``,
|
|
81
|
+
}
|
|
82
|
+
return { short: `https://${domain}/${code}` }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Append one line to the box's device registry. False if it could not be recorded. */
|
|
86
|
+
export async function registerDevice(
|
|
87
|
+
deps: Pick<TunnelLinkDeps, 'root' | 'ssh'>,
|
|
88
|
+
device: { id: string; project: string; label: string },
|
|
89
|
+
issued = new Date()
|
|
90
|
+
): Promise<boolean> {
|
|
91
|
+
const line = deviceRegistryLine({ ...device, issued })
|
|
92
|
+
if (!line) return false
|
|
93
|
+
const run = await deps.ssh(
|
|
94
|
+
`mkdir -p ${deps.root}/_devices && printf '%s\\n' '${line}' >> ${deps.root}/_devices/registry.tsv`
|
|
95
|
+
)
|
|
96
|
+
return run.exitCode === 0
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/*
|
|
100
|
+
Single-use links that also give the browser the box's global key (see GlobalGrant in
|
|
101
|
+
dev-auth.ts): one click opens this workspace, and from then on that browser opens every
|
|
102
|
+
workspace holding the key, and the box's root list, for 30 days, across restarts. The link
|
|
103
|
+
carries a one-use code, never the key. Offered only for keys this workspace accepts and the box
|
|
104
|
+
holds too; `notes` says why any other is missing.
|
|
105
|
+
*/
|
|
106
|
+
export async function globalLinks(
|
|
107
|
+
deps: TunnelLinkDeps,
|
|
108
|
+
kinds: LinkKind[] = ['edit', 'view']
|
|
109
|
+
): Promise<{ links: GlobalLink[]; notes: string[] }> {
|
|
110
|
+
const { local, label } = deps
|
|
111
|
+
const box: BoxKeys =
|
|
112
|
+
local.keys.edit || local.keys.view ? await deps.boxKeys() : {}
|
|
113
|
+
const plan = planGlobalLinks(local, box)
|
|
114
|
+
const links: GlobalLink[] = []
|
|
115
|
+
for (const kind of plan.kinds.filter((k) => kinds.includes(k))) {
|
|
116
|
+
const answer = await deps.askForLink(
|
|
117
|
+
`?global=${kind}&domain=${encodeURIComponent(plan.domain!)}${
|
|
118
|
+
label ? `&label=${encodeURIComponent(label)}` : ''
|
|
119
|
+
}`
|
|
120
|
+
)
|
|
121
|
+
if (answer.url && answer.device) {
|
|
122
|
+
// Recorded before the link is shown, so every pass in a browser is on the box's list.
|
|
123
|
+
const recorded = await registerDevice(deps, {
|
|
124
|
+
id: answer.device,
|
|
125
|
+
project: deps.projectName,
|
|
126
|
+
// The links printed at start are recorded too, used or not; say which they were.
|
|
127
|
+
label: label ?? (deps.onRequest ? '' : 'printed at tunnel start'),
|
|
128
|
+
})
|
|
129
|
+
if (!recorded) {
|
|
130
|
+
plan.notes.push(
|
|
131
|
+
`could not record device ${answer.device} on ${deps.host}, so no ${kind} link: it could not be listed or revoked`
|
|
132
|
+
)
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (answer.url) {
|
|
137
|
+
const { short, problem } = await registerShortCode(
|
|
138
|
+
deps,
|
|
139
|
+
answer.url,
|
|
140
|
+
plan.domain!
|
|
141
|
+
)
|
|
142
|
+
if (!short)
|
|
143
|
+
plan.notes.push(`no short link (${problem}); the full link still works`)
|
|
144
|
+
links.push({ kind, url: answer.url, short })
|
|
145
|
+
} else
|
|
146
|
+
plan.notes.push(
|
|
147
|
+
answer.error ??
|
|
148
|
+
`the dev server on :${deps.localPort} did not answer, so no ${kind} link: start it (\`bun start\`), then \`tosijs-tunnel --link\``
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
return { links, notes: plan.notes }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** What the tunnel prints under its public URL at start: the links, then the notes. */
|
|
155
|
+
export function startLinkLines(start: {
|
|
156
|
+
links: GlobalLink[]
|
|
157
|
+
notes: string[]
|
|
158
|
+
}): string[] {
|
|
159
|
+
const lines: string[] = []
|
|
160
|
+
if (start.links.length) {
|
|
161
|
+
lines.push(
|
|
162
|
+
`\n Single-use links: each opens this workspace, then keeps that browser in every` +
|
|
163
|
+
`\n workspace on the box (and its root list) for 30 days:`
|
|
164
|
+
)
|
|
165
|
+
for (const { kind, url, short } of start.links) {
|
|
166
|
+
const code = new URL(url).searchParams.get('t') ?? ''
|
|
167
|
+
lines.push(
|
|
168
|
+
` ${kind}: ${url}${kind === 'view' ? ' (read-only)' : ''}` +
|
|
169
|
+
(short ? `\n ${short} code ${groupCode(code)}` : '')
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const note of start.notes) lines.push(` note: ${note}`)
|
|
174
|
+
return lines
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The real `askForLink`: the dev server's `/__devlink`, on loopback, over its TLS port. */
|
|
178
|
+
export async function askDevServer(
|
|
179
|
+
localPort: number,
|
|
180
|
+
query: string
|
|
181
|
+
): Promise<DevLinkAnswer> {
|
|
182
|
+
const res =
|
|
183
|
+
await $`curl -sk --max-time 5 https://localhost:${localPort}/__devlink${query}`
|
|
184
|
+
.nothrow()
|
|
185
|
+
.quiet()
|
|
186
|
+
try {
|
|
187
|
+
return JSON.parse(res.stdout.toString())
|
|
188
|
+
} catch {
|
|
189
|
+
return {}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The real `ssh`: never prompts or hangs, since a link is waiting on it. */
|
|
194
|
+
export async function sshTo(
|
|
195
|
+
host: string,
|
|
196
|
+
command: string
|
|
197
|
+
): Promise<{ exitCode: number }> {
|
|
198
|
+
return $`ssh -o BatchMode=yes -o ConnectTimeout=10 ${host} ${command}`
|
|
199
|
+
.nothrow()
|
|
200
|
+
.quiet()
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The real `fetchText`. */
|
|
204
|
+
export async function fetchText(url: string): Promise<string> {
|
|
205
|
+
return fetch(url, { signal: AbortSignal.timeout(5000) })
|
|
206
|
+
.then((res) => (res.ok ? res.text() : ''))
|
|
207
|
+
.catch(() => '')
|
|
208
|
+
}
|
package/bin/tunnel.ts
CHANGED
|
@@ -32,7 +32,14 @@ always requires a session.
|
|
|
32
32
|
|
|
33
33
|
import { $ } from 'bun'
|
|
34
34
|
import {
|
|
35
|
-
|
|
35
|
+
askDevServer,
|
|
36
|
+
fetchText,
|
|
37
|
+
globalLinks as globalLinksFor,
|
|
38
|
+
sshTo,
|
|
39
|
+
startLinkLines,
|
|
40
|
+
type LinkKind,
|
|
41
|
+
} from './tunnel-links.ts'
|
|
42
|
+
import {
|
|
36
43
|
previewBoxKeys,
|
|
37
44
|
previewRootFor,
|
|
38
45
|
readLocalSecret,
|
|
@@ -44,9 +51,7 @@ import {
|
|
|
44
51
|
resolveSiteConfig,
|
|
45
52
|
resolveTunnelLocalPort,
|
|
46
53
|
parseArgv,
|
|
47
|
-
deviceRegistryLine,
|
|
48
54
|
groupCode,
|
|
49
|
-
shortCodeScript,
|
|
50
55
|
parseDeviceListing,
|
|
51
56
|
DEVICE_LISTING_SEPARATOR,
|
|
52
57
|
DEVICE_ID_PATTERN,
|
|
@@ -148,84 +153,11 @@ const publicUrl = flag('url') ?? preview?.tunnel?.url
|
|
|
148
153
|
// Set by the registration step below; no public URL means there is no route to fail.
|
|
149
154
|
let routed = true
|
|
150
155
|
|
|
151
|
-
/** Ask the dev server (loopback only) for a link; `query` selects the kind. */
|
|
152
|
-
async function askForLink(
|
|
153
|
-
query: string
|
|
154
|
-
): Promise<{ url?: string; device?: string; error?: string }> {
|
|
155
|
-
const res =
|
|
156
|
-
await $`curl -sk --max-time 5 https://localhost:${localPort}/__devlink${query}`
|
|
157
|
-
.nothrow()
|
|
158
|
-
.quiet()
|
|
159
|
-
try {
|
|
160
|
-
return JSON.parse(res.stdout.toString())
|
|
161
|
-
} catch {
|
|
162
|
-
return {}
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
156
|
/*
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
workspace holding the key, and the box's root list, for 30 days, across restarts. The link
|
|
170
|
-
carries a one-use code, never the key. Offered only for keys this workspace accepts and the box
|
|
171
|
-
holds too; `notes` says why any other is missing.
|
|
157
|
+
The link logic lives in tunnel-links.ts, with everything that reaches the network passed in, so
|
|
158
|
+
it can be tested without a box. These are the real ones.
|
|
172
159
|
*/
|
|
173
|
-
|
|
174
|
-
kinds: ('edit' | 'view')[] = ['edit', 'view']
|
|
175
|
-
): Promise<{
|
|
176
|
-
links: { kind: 'edit' | 'view'; url: string; short?: string }[]
|
|
177
|
-
notes: string[]
|
|
178
|
-
}> {
|
|
179
|
-
const local = resolveGlobalKeys(preview?.tunnel?.globalKeys, {
|
|
180
|
-
PREVIEW_TOKEN:
|
|
181
|
-
process.env.PREVIEW_TOKEN ?? readLocalSecret('PREVIEW_TOKEN'),
|
|
182
|
-
PREVIEW_EDIT_TOKEN:
|
|
183
|
-
process.env.PREVIEW_EDIT_TOKEN ?? readLocalSecret('PREVIEW_EDIT_TOKEN'),
|
|
184
|
-
})
|
|
185
|
-
const box =
|
|
186
|
-
local.keys.edit || local.keys.view ? await previewBoxKeys(host!) : {}
|
|
187
|
-
const plan = planGlobalLinks(local, box)
|
|
188
|
-
const links: { kind: 'edit' | 'view'; url: string; short?: string }[] = []
|
|
189
|
-
for (const kind of plan.kinds.filter((k) => kinds.includes(k))) {
|
|
190
|
-
const label = flag('label')
|
|
191
|
-
const answer = await askForLink(
|
|
192
|
-
`?global=${kind}&domain=${encodeURIComponent(plan.domain!)}${
|
|
193
|
-
label ? `&label=${encodeURIComponent(label)}` : ''
|
|
194
|
-
}`
|
|
195
|
-
)
|
|
196
|
-
if (answer.url && answer.device) {
|
|
197
|
-
// Recorded before the link is shown, so every pass in a browser is on the box's list.
|
|
198
|
-
const recorded = await registerDevice({
|
|
199
|
-
id: answer.device,
|
|
200
|
-
project: projectName,
|
|
201
|
-
// The links printed at start are recorded too, used or not; say which they were.
|
|
202
|
-
label: label ?? (has('link') ? '' : 'printed at tunnel start'),
|
|
203
|
-
})
|
|
204
|
-
if (!recorded) {
|
|
205
|
-
plan.notes.push(
|
|
206
|
-
`could not record device ${answer.device} on ${host}, so no ${kind} link: it could not be listed or revoked`
|
|
207
|
-
)
|
|
208
|
-
continue
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
if (answer.url) {
|
|
212
|
-
const short = await registerShortCode(answer.url, plan.domain!)
|
|
213
|
-
if (!short)
|
|
214
|
-
plan.notes.push(
|
|
215
|
-
`no short link (${
|
|
216
|
-
shortLinkProblem || `could not record it on ${host}`
|
|
217
|
-
}); the full link still works`
|
|
218
|
-
)
|
|
219
|
-
links.push({ kind, url: answer.url, short })
|
|
220
|
-
} else
|
|
221
|
-
plan.notes.push(
|
|
222
|
-
answer.error ??
|
|
223
|
-
`the dev server on :${localPort} did not answer, so no ${kind} link: start it (\`bun start\`), then \`tosijs-tunnel --link\``
|
|
224
|
-
)
|
|
225
|
-
}
|
|
226
|
-
return { links, notes: plan.notes }
|
|
227
|
-
}
|
|
228
|
-
let startLinks: Awaited<ReturnType<typeof globalLinks>> | undefined
|
|
160
|
+
const askForLink = (query: string) => askDevServer(localPort, query)
|
|
229
161
|
|
|
230
162
|
/*
|
|
231
163
|
DEVICES (board #3416). Each --global edit link is a pass for one device. The box keeps the
|
|
@@ -239,55 +171,30 @@ const devicesRoot = () =>
|
|
|
239
171
|
preview?.caddySitesDir
|
|
240
172
|
)
|
|
241
173
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
(
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
): Promise<string | undefined> {
|
|
267
|
-
const code = new URL(url).searchParams.get('t') ?? ''
|
|
268
|
-
const script = shortCodeScript(devicesRoot(), code, url)
|
|
269
|
-
if (!script) {
|
|
270
|
-
shortLinkProblem = `${url} is not a link the box can shorten`
|
|
271
|
-
return undefined
|
|
272
|
-
}
|
|
273
|
-
const run =
|
|
274
|
-
await $`ssh -o BatchMode=yes -o ConnectTimeout=10 ${host!} ${script}`
|
|
275
|
-
.nothrow()
|
|
276
|
-
.quiet()
|
|
277
|
-
if (run.exitCode !== 0) return undefined
|
|
278
|
-
// Read it back through Caddy: a box whose Caddyfile predates short links answers with its
|
|
279
|
-
// gate, and a short link printed over that would lead nowhere.
|
|
280
|
-
const served = await fetch(`https://${domain}/_codes/${code}`, {
|
|
281
|
-
signal: AbortSignal.timeout(5000),
|
|
282
|
-
})
|
|
283
|
-
.then((res) => (res.ok ? res.text() : ''))
|
|
284
|
-
.catch(() => '')
|
|
285
|
-
if (served.trim() !== url) {
|
|
286
|
-
shortLinkProblem = `the box's Caddyfile does not serve short links yet: run \`tosijs-caddy-install --go\``
|
|
287
|
-
return undefined
|
|
288
|
-
}
|
|
289
|
-
return `https://${domain}/${code}`
|
|
290
|
-
}
|
|
174
|
+
const globalLinks = (kinds?: LinkKind[]) =>
|
|
175
|
+
globalLinksFor(
|
|
176
|
+
{
|
|
177
|
+
host: host!,
|
|
178
|
+
root: devicesRoot(),
|
|
179
|
+
projectName,
|
|
180
|
+
localPort,
|
|
181
|
+
label: flag('label'),
|
|
182
|
+
onRequest: has('link'),
|
|
183
|
+
local: resolveGlobalKeys(preview?.tunnel?.globalKeys, {
|
|
184
|
+
PREVIEW_TOKEN:
|
|
185
|
+
process.env.PREVIEW_TOKEN ?? readLocalSecret('PREVIEW_TOKEN'),
|
|
186
|
+
PREVIEW_EDIT_TOKEN:
|
|
187
|
+
process.env.PREVIEW_EDIT_TOKEN ??
|
|
188
|
+
readLocalSecret('PREVIEW_EDIT_TOKEN'),
|
|
189
|
+
}),
|
|
190
|
+
boxKeys: () => previewBoxKeys(host!),
|
|
191
|
+
ssh: (command) => sshTo(host!, command),
|
|
192
|
+
askForLink,
|
|
193
|
+
fetchText,
|
|
194
|
+
},
|
|
195
|
+
kinds
|
|
196
|
+
)
|
|
197
|
+
let startLinks: Awaited<ReturnType<typeof globalLinks>> | undefined
|
|
291
198
|
|
|
292
199
|
/*
|
|
293
200
|
Say where this is going, and where the address came from, BEFORE acting on it.
|
|
@@ -703,20 +610,8 @@ if (publicUrl) {
|
|
|
703
610
|
? ` ${publicUrl}`
|
|
704
611
|
: ` ${publicUrl} ⚠️ NOT routed — registration failed above; this URL will not reach here`
|
|
705
612
|
)
|
|
706
|
-
if (startLinks
|
|
707
|
-
console.log(
|
|
708
|
-
`\n Single-use links: each opens this workspace, then keeps that browser in every` +
|
|
709
|
-
`\n workspace on the box (and its root list) for 30 days:`
|
|
710
|
-
)
|
|
711
|
-
for (const { kind, url, short } of startLinks.links) {
|
|
712
|
-
const code = new URL(url).searchParams.get('t') ?? ''
|
|
713
|
-
console.log(
|
|
714
|
-
` ${kind}: ${url}${kind === 'view' ? ' (read-only)' : ''}` +
|
|
715
|
-
(short ? `\n ${short} code ${groupCode(code)}` : '')
|
|
716
|
-
)
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
for (const note of startLinks?.notes ?? []) console.log(` note: ${note}`)
|
|
613
|
+
if (startLinks)
|
|
614
|
+
for (const line of startLinkLines(startLinks)) console.log(line)
|
|
720
615
|
}
|
|
721
616
|
console.log(` Ctrl-C to close.\n`)
|
|
722
617
|
|
package/dist/carousel.d.ts
CHANGED
|
@@ -145,6 +145,7 @@ export declare class TosiCarousel extends TosiCarousel_base {
|
|
|
145
145
|
handleDotClick: (event: Event) => void;
|
|
146
146
|
private snapTimer;
|
|
147
147
|
private animationFrame;
|
|
148
|
+
private cancelAnimation;
|
|
148
149
|
animateScroll(position: number, startingPosition?: number, timestamp?: number): void;
|
|
149
150
|
content: () => HTMLDivElement[];
|
|
150
151
|
connectedCallback(): void;
|