@timqi/pier 0.0.16 → 0.0.17
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/dist/db.js +61 -0
- package/dist/settings.js +2 -11
- package/dist/tools.js +10 -16
- package/dist/web/auth.js +215 -65
- package/dist/web/public/assets/{ghostty-web-C4ivXTBE.js → ghostty-web-CV7F9_iW.js} +1 -1
- package/dist/web/public/assets/{index-2E9_cwpg.css → index-CtR-Ob4T.css} +1 -1
- package/dist/web/public/assets/{index-DVUvzNK1.js → index-DAxKtidH.js} +19 -19
- package/dist/web/public/index.html +2 -2
- package/dist/web/push.js +30 -7
- package/dist/web/terminal.js +19 -9
- package/docs/deploy.md +12 -3
- package/package.json +1 -1
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
which style.css pays for with the safe-area inset. -->
|
|
34
34
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
35
35
|
<title>Pier</title>
|
|
36
|
-
<script type="module" crossorigin src="/assets/index-
|
|
37
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
36
|
+
<script type="module" crossorigin src="/assets/index-DAxKtidH.js"></script>
|
|
37
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CtR-Ob4T.css">
|
|
38
38
|
</head>
|
|
39
39
|
<!-- The document never scrolls: this is a fixed-viewport workbench, and every
|
|
40
40
|
scrollable region is an inner pane. h-dvh, not h-screen, because 100vh can
|
package/dist/web/push.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { readableTitle } from "../core/identity.js";
|
|
11
11
|
import { pierDb } from "../db.js";
|
|
12
12
|
import { logger } from "../log.js";
|
|
13
|
+
import { sessionIdOf } from "./auth.js";
|
|
13
14
|
import { generateVapidKeys, sendPush, } from "./webpush.js";
|
|
14
15
|
const log = logger("push");
|
|
15
16
|
/** Devices kept at once. A browser mints a new subscription whenever the old
|
|
@@ -29,6 +30,10 @@ const MAX_BODY_CHARS = 160;
|
|
|
29
30
|
* browser bug rather than as an unnamed session — and a listing that could
|
|
30
31
|
* not answer must not silence the push. */
|
|
31
32
|
const label = (s) => readableTitle(s?.title) || s?.cwd.split("/").filter(Boolean).at(-1) || "Pier session";
|
|
33
|
+
/** SQLite's "that parent row does not exist" — the session ended. Anything
|
|
34
|
+
* else that goes wrong here is a broken database, not a signed-out browser. */
|
|
35
|
+
const FOREIGN_KEY_VIOLATION = 787;
|
|
36
|
+
const sessionGone = (err) => err.errcode === FOREIGN_KEY_VIOLATION;
|
|
32
37
|
/** Subscriptions and the instance's VAPID identity. Both are per-instance
|
|
33
38
|
* facts nobody edits by hand, so they live beside every other one. */
|
|
34
39
|
export class PushStore {
|
|
@@ -59,14 +64,16 @@ export class PushStore {
|
|
|
59
64
|
.all();
|
|
60
65
|
}
|
|
61
66
|
/** Upsert: a browser re-posts the same subscription on every load, which is
|
|
62
|
-
* what repairs a row this instance lost
|
|
63
|
-
|
|
67
|
+
* what repairs a row this instance lost — and what re-attaches one to the
|
|
68
|
+
* session that is signed in now. */
|
|
69
|
+
save(target, label, sessionId) {
|
|
64
70
|
this.#db
|
|
65
|
-
.prepare(`INSERT INTO push_subscriptions(endpoint, p256dh, auth, label, created_at)
|
|
66
|
-
VALUES (?, ?, ?, ?, ?)
|
|
71
|
+
.prepare(`INSERT INTO push_subscriptions(endpoint, p256dh, auth, label, created_at, session_id)
|
|
72
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
67
73
|
ON CONFLICT(endpoint) DO UPDATE SET
|
|
68
|
-
p256dh = excluded.p256dh, auth = excluded.auth, label = excluded.label
|
|
69
|
-
|
|
74
|
+
p256dh = excluded.p256dh, auth = excluded.auth, label = excluded.label,
|
|
75
|
+
session_id = excluded.session_id`)
|
|
76
|
+
.run(target.endpoint, target.p256dh, target.auth, label, Date.now(), sessionId);
|
|
70
77
|
this.#db
|
|
71
78
|
.prepare(`DELETE FROM push_subscriptions WHERE endpoint NOT IN
|
|
72
79
|
(SELECT endpoint FROM push_subscriptions ORDER BY created_at DESC LIMIT ?)`)
|
|
@@ -222,7 +229,23 @@ export function registerPushRoutes(app, deps) {
|
|
|
222
229
|
if (!target)
|
|
223
230
|
return c.json({ error: "not a push subscription" }, 400);
|
|
224
231
|
const label = String(body.label ?? "a browser").slice(0, 80);
|
|
225
|
-
|
|
232
|
+
// The session that is asking owns the subscription, and the foreign key is
|
|
233
|
+
// what makes signing this browser out take the subscription with it. It can
|
|
234
|
+
// refuse: the boundary let this request in and the browser was signed out
|
|
235
|
+
// while its body was still arriving. Say so rather than 500 — the browser
|
|
236
|
+
// is about to be sent to the login form by its next request anyway.
|
|
237
|
+
try {
|
|
238
|
+
store.save(target, label, sessionIdOf(c));
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
// Only that. A full or read-only database answering 401 would send a
|
|
242
|
+
// signed-in browser to the login form, where the password it types will
|
|
243
|
+
// not help either.
|
|
244
|
+
if (!sessionGone(err))
|
|
245
|
+
throw err;
|
|
246
|
+
log.warn(`subscription refused for a session that ended: ${String(err)}`);
|
|
247
|
+
return c.json({ error: "session ended" }, 401);
|
|
248
|
+
}
|
|
226
249
|
log.info(`subscribed ${label}`);
|
|
227
250
|
return c.json({ ok: true }, 201);
|
|
228
251
|
});
|
package/dist/web/terminal.js
CHANGED
|
@@ -11,7 +11,7 @@ import { isAbsolute } from "node:path";
|
|
|
11
11
|
import { spawn } from "node-pty";
|
|
12
12
|
import { WebSocketServer } from "ws";
|
|
13
13
|
import { logger } from "../log.js";
|
|
14
|
-
import { upgradeAuthorized } from "./auth.js";
|
|
14
|
+
import { ALL, upgradeAuthorized } from "./auth.js";
|
|
15
15
|
const log = logger("terminal");
|
|
16
16
|
/** Live shells at once — a guard against forgotten spawns, not a quota. */
|
|
17
17
|
const MAX_TERMS = 8;
|
|
@@ -276,10 +276,18 @@ export function attachTerminal(server, auth, opts = {}) {
|
|
|
276
276
|
});
|
|
277
277
|
server.once("close", () => hub.close());
|
|
278
278
|
wss.on("error", (err) => log.error("terminal WebSocket server failed", err));
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
279
|
+
// A shell outlives the request that opened it, so revocation has to reach it
|
|
280
|
+
// here: the session that opened this socket was signed out (or every session
|
|
281
|
+
// was, by a password change), and the terminal goes with it.
|
|
282
|
+
const sessionOf = new WeakMap();
|
|
283
|
+
const revoked = new WeakSet();
|
|
284
|
+
auth.onRevoke((id) => {
|
|
285
|
+
for (const client of wss.clients) {
|
|
286
|
+
if (id !== ALL && sessionOf.get(client) !== id)
|
|
287
|
+
continue;
|
|
288
|
+
revoked.add(client);
|
|
289
|
+
client.close(1008, "signed out");
|
|
290
|
+
}
|
|
283
291
|
});
|
|
284
292
|
server.on("upgrade", (req, socket, head) => {
|
|
285
293
|
let url;
|
|
@@ -296,14 +304,15 @@ export function attachTerminal(server, auth, opts = {}) {
|
|
|
296
304
|
socket.destroy();
|
|
297
305
|
return;
|
|
298
306
|
}
|
|
299
|
-
|
|
307
|
+
const sessionId = upgradeAuthorized(auth, req);
|
|
308
|
+
if (!sessionId) {
|
|
300
309
|
log.warn("refused terminal upgrade (unauthorized)");
|
|
301
310
|
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
302
311
|
socket.destroy();
|
|
303
312
|
return;
|
|
304
313
|
}
|
|
305
|
-
const authKey = auth.cookieKey;
|
|
306
314
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
315
|
+
sessionOf.set(ws, sessionId);
|
|
307
316
|
alive.add(ws);
|
|
308
317
|
ws.on("pong", () => alive.add(ws));
|
|
309
318
|
// Frames can land while attach() is resolving realpath; hold a bounded
|
|
@@ -320,9 +329,10 @@ export function attachTerminal(server, auth, opts = {}) {
|
|
|
320
329
|
ws.on("message", (data, binary) => {
|
|
321
330
|
if (closed)
|
|
322
331
|
return;
|
|
323
|
-
|
|
332
|
+
// A frame can already be in flight when the close above goes out.
|
|
333
|
+
if (revoked.has(ws)) {
|
|
324
334
|
closed = true;
|
|
325
|
-
ws.close(1008, "
|
|
335
|
+
ws.close(1008, "signed out");
|
|
326
336
|
return;
|
|
327
337
|
}
|
|
328
338
|
if (binary) {
|
package/docs/deploy.md
CHANGED
|
@@ -229,8 +229,16 @@ sqlite3 ~/.pier/db/pier.db 'DELETE FROM auth'
|
|
|
229
229
|
pier restart
|
|
230
230
|
```
|
|
231
231
|
|
|
232
|
-
Changing the password
|
|
233
|
-
|
|
232
|
+
Changing the password signs out every browser: the session rows behind the
|
|
233
|
+
cookies are deleted. So does the recovery above — a new password does not leave
|
|
234
|
+
the old one's browsers signed in. To end one browser without changing the
|
|
235
|
+
password, use Settings → Instance → Signed-in devices. A session that is not
|
|
236
|
+
used expires 7 days after its last request. However a session ends — signed
|
|
237
|
+
out, expired, password changed, password recovered — its push subscription is
|
|
238
|
+
deleted with it, so that browser stops being notified as well.
|
|
239
|
+
|
|
240
|
+
The upgrade that introduced these rows signs everyone out once: a cookie issued
|
|
241
|
+
before it names no row. Have the password to hand.
|
|
234
242
|
|
|
235
243
|
## Restarting and reloading
|
|
236
244
|
|
|
@@ -400,7 +408,8 @@ elsewhere, pick a tunnel rather than a wider bind:
|
|
|
400
408
|
there, preserve the external `Host` (or pass `X-Forwarded-Host`), and pass
|
|
401
409
|
`X-Forwarded-For`; Pier uses the external host for write-origin checks and
|
|
402
410
|
counts login failures per forwarded client. Its session cookie is marked
|
|
403
|
-
`Secure` when
|
|
411
|
+
`Secure` when a loopback proxy reports `X-Forwarded-Proto: https` (the header
|
|
412
|
+
is ignored from anywhere else, where it is a header the client wrote). The proxy must
|
|
404
413
|
pass WebSocket upgrades for `/api/terminal` (Caddy does automatically;
|
|
405
414
|
nginx needs its usual HTTP/1.1 `Upgrade`/`Connection` forwarding).
|
|
406
415
|
|
package/package.json
CHANGED