@oar-sh/oar 0.9.8 → 0.9.9
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 +231 -0
- package/README.md +53 -32
- package/bin/oar.js +814 -228
- package/package.json +4 -5
- package/server/README.md +20 -1
- package/server/db-schema.mjs +8 -0
- package/server/migrations/0008-message-pins.mjs +85 -0
- package/server/public/app/api-client.js +30 -0
- package/server/public/app/attachments-view.js +508 -125
- package/server/public/app/back-guard.mjs +58 -0
- package/server/public/app/bootstrap.js +46 -2
- package/server/public/app/conversation-view.js +110 -25
- package/server/public/app/external-link-policy.mjs +4 -1
- package/server/public/app/file-version.mjs +58 -0
- package/server/public/app/journal-view.js +16 -2
- package/server/public/app/pinned-messages-view.mjs +253 -0
- package/server/public/app/pwa-install.js +1 -1
- package/server/public/app/relay-dot-state.mjs +8 -1
- package/server/public/app/router.js +8 -2
- package/server/public/app/socket-handlers.js +6 -0
- package/server/public/app/store.js +6 -0
- package/server/public/app/viewer-chat-gallery.mjs +59 -0
- package/server/public/app/viewer-controls-state.mjs +59 -0
- package/server/public/app/viewer-gallery.mjs +46 -0
- package/server/public/app/viewer-gestures.mjs +105 -0
- package/server/public/app/viewer-history.mjs +43 -0
- package/server/public/app/viewer-tap.mjs +112 -0
- package/server/public/index.html +304 -54
- package/server/repositories/message-pin-statements.mjs +39 -0
- package/server/repositories/message-repository.mjs +5 -0
- package/server/routes/messages-routes.mjs +33 -61
- package/server/routes/sessions-routes.mjs +92 -24
- package/server/server-runtime.mjs +29 -0
- package/server/services/cloudflared-tunnel-service.mjs +139 -41
- package/server/services/file-serving.mjs +155 -0
- package/server/services/message-pin-list.mjs +52 -0
- package/server/services/oar-cli-helpers.mjs +139 -4
- package/server/services/oar-cli-menu.mjs +240 -0
- package/server/services/oar-cli-wsl.mjs +129 -0
- package/server/services/safe-served-content.mjs +61 -11
- package/server/services/session-history-refresh-service.mjs +24 -0
|
@@ -2,16 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import fs from 'fs';
|
|
5
|
-
import { createRequire } from 'module';
|
|
6
5
|
import { spawn } from 'child_process';
|
|
7
6
|
|
|
8
|
-
const requireFromHere = createRequire(import.meta.url);
|
|
9
|
-
|
|
10
7
|
const BACKOFF_STEPS = [5_000, 10_000, 20_000, 40_000, 60_000];
|
|
11
8
|
const READINESS_WINDOW_MS = 5_000;
|
|
12
9
|
const STABLE_CONNECTION_MS = 30_000;
|
|
13
10
|
const FAST_EXIT_MS = 10_000;
|
|
14
11
|
const MAX_CONSECUTIVE_FAST_EXITS = 3;
|
|
12
|
+
const BINARY_RECHECK_MS = 60_000;
|
|
13
|
+
// What Windows starts without a shell. A `.cmd`/`.bat` launcher would need
|
|
14
|
+
// cmd.exe, and the tunnel token does not go through a shell command line.
|
|
15
|
+
const WINDOWS_DIRECT_EXTENSIONS = ['.exe', '.com'];
|
|
15
16
|
|
|
16
17
|
function toText(value) {
|
|
17
18
|
return String(value || '').trim();
|
|
@@ -29,28 +30,91 @@ function normalizeExtraArgs(rawValue) {
|
|
|
29
30
|
return rawValue.map((entry) => toText(entry)).filter(Boolean);
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
33
|
+
function hasDirectoryPart(value) {
|
|
34
|
+
return value.includes('/') || value.includes('\\');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Where the cloudflared the tunnel would run really is, or null. OAR ships no
|
|
39
|
+
* copy: a name is looked up on PATH (on Windows with the `.exe`/`.com`
|
|
40
|
+
* suffixes PATHEXT lists), a path must be an executable file.
|
|
41
|
+
*/
|
|
42
|
+
export function locateCloudflaredBinary(binary, {
|
|
43
|
+
platform = process.platform,
|
|
44
|
+
env = process.env,
|
|
45
|
+
fsImpl = fs,
|
|
46
|
+
pathImpl = platform === 'win32' ? path.win32 : path.posix,
|
|
40
47
|
} = {}) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
+
const name = toText(binary);
|
|
49
|
+
if (!name) return null;
|
|
50
|
+
|
|
51
|
+
let names = [name];
|
|
52
|
+
if (platform === 'win32' && !WINDOWS_DIRECT_EXTENSIONS.includes(pathImpl.extname(name).toLowerCase())) {
|
|
53
|
+
const listed = String(env?.PATHEXT || '')
|
|
54
|
+
.split(';')
|
|
55
|
+
.map((ext) => toText(ext).toLowerCase())
|
|
56
|
+
.filter((ext) => WINDOWS_DIRECT_EXTENSIONS.includes(ext));
|
|
57
|
+
names = (listed.length ? listed : WINDOWS_DIRECT_EXTENSIONS).map((ext) => `${name}${ext}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const isExecutableFile = (candidate) => {
|
|
61
|
+
try {
|
|
62
|
+
if (!fsImpl.statSync(candidate).isFile()) return false;
|
|
63
|
+
// Windows has no execute bit; the suffix above decided.
|
|
64
|
+
if (platform !== 'win32') fsImpl.accessSync(candidate, fs.constants.X_OK);
|
|
65
|
+
return true;
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const dirs = hasDirectoryPart(name)
|
|
72
|
+
? [null]
|
|
73
|
+
: String(env?.PATH ?? env?.Path ?? '').split(pathImpl.delimiter).map(toText).filter(Boolean);
|
|
74
|
+
for (const dir of dirs) {
|
|
75
|
+
for (const candidate of names) {
|
|
76
|
+
const full = dir === null ? candidate : pathImpl.join(dir, candidate);
|
|
77
|
+
if (isExecutableFile(full)) return full;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
48
80
|
return null;
|
|
49
81
|
}
|
|
50
82
|
|
|
83
|
+
export function cloudflaredInstallPointer(platform = process.platform) {
|
|
84
|
+
if (platform === 'win32') return 'install it with: winget install --id Cloudflare.cloudflared';
|
|
85
|
+
if (platform === 'darwin') return 'install it with: brew install cloudflared';
|
|
86
|
+
return "install it from Cloudflare's package repository: https://pkg.cloudflare.com/";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The one sentence the tunnel state, the status dot and the CLI show. */
|
|
90
|
+
export function describeMissingCloudflaredBinary({ binary = '', binarySource = 'path', platform = process.platform } = {}) {
|
|
91
|
+
const pointer = cloudflaredInstallPointer(platform);
|
|
92
|
+
if (binarySource === 'config') {
|
|
93
|
+
return `cloudflared was not found as configured (${binary}) — correct cloudflaredTunnel.binary, or ${pointer}`;
|
|
94
|
+
}
|
|
95
|
+
return `cloudflared is not installed — ${pointer}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The tunnel in one line for a CLI status row. Takes the manager's state or
|
|
100
|
+
* the `cloudflaredTunnel` object of `/api/status`; the public address is the
|
|
101
|
+
* caller's to pass, the relay does not know it (the route lives in Cloudflare).
|
|
102
|
+
*/
|
|
103
|
+
export function describeTunnelState(state, { url = '' } = {}) {
|
|
104
|
+
const tunnel = state && typeof state === 'object' ? state : {};
|
|
105
|
+
const managed = toText(tunnel.mode).toLowerCase() === 'managed' || tunnel.enabled === true;
|
|
106
|
+
if (!managed) return 'off';
|
|
107
|
+
const reason = toText(tunnel.lastError);
|
|
108
|
+
if (tunnel.connected === true) {
|
|
109
|
+
const address = toText(url);
|
|
110
|
+
return address ? `running (${address})` : 'running';
|
|
111
|
+
}
|
|
112
|
+
if (tunnel.binaryMissing === true || tunnel.valid === false) return reason ? `off: ${reason}` : 'off';
|
|
113
|
+
return reason ? `not connected (${reason})` : 'not connected';
|
|
114
|
+
}
|
|
115
|
+
|
|
51
116
|
export function normalizeCloudflaredTunnelConfig(rawConfig = {}, {
|
|
52
117
|
env = process.env,
|
|
53
|
-
resolveBinary = resolveCloudflaredBinaryFromPackage,
|
|
54
118
|
configBaseDir = process.cwd(),
|
|
55
119
|
pathImpl = path,
|
|
56
120
|
} = {}) {
|
|
@@ -64,33 +128,20 @@ export function normalizeCloudflaredTunnelConfig(rawConfig = {}, {
|
|
|
64
128
|
const binaryInput = toText(env?.COPILOT_CLOUDFLARED_BINARY) || toText(raw.binary);
|
|
65
129
|
const extraArgs = normalizeExtraArgs(raw.extraArgs);
|
|
66
130
|
|
|
67
|
-
|
|
68
|
-
|
|
131
|
+
// The configured binary, else `cloudflared` from PATH. Whether it is really
|
|
132
|
+
// there is checked when the tunnel starts (locateCloudflaredBinary).
|
|
133
|
+
let binary = 'cloudflared';
|
|
134
|
+
let binarySource = 'path';
|
|
69
135
|
if (binaryInput) {
|
|
70
|
-
binary =
|
|
136
|
+
binary = hasDirectoryPart(binaryInput)
|
|
71
137
|
? pathImpl.resolve(configBaseDir, binaryInput)
|
|
72
138
|
: binaryInput;
|
|
73
139
|
binarySource = 'config';
|
|
74
|
-
} else {
|
|
75
|
-
let packageBinary = null;
|
|
76
|
-
try {
|
|
77
|
-
packageBinary = toText(typeof resolveBinary === 'function' ? resolveBinary() : '');
|
|
78
|
-
} catch {
|
|
79
|
-
packageBinary = null;
|
|
80
|
-
}
|
|
81
|
-
if (packageBinary) {
|
|
82
|
-
binary = packageBinary;
|
|
83
|
-
binarySource = 'package';
|
|
84
|
-
} else {
|
|
85
|
-
binary = 'cloudflared';
|
|
86
|
-
binarySource = 'path';
|
|
87
|
-
}
|
|
88
140
|
}
|
|
89
141
|
|
|
90
142
|
const errors = [];
|
|
91
143
|
if (mode === 'managed') {
|
|
92
144
|
if (!token) errors.push('cloudflaredTunnel.token is required when cloudflaredTunnel.mode is "managed"');
|
|
93
|
-
if (!binary) errors.push('cloudflaredTunnel.binary could not be resolved (install the "cloudflared" package or set cloudflaredTunnel.binary)');
|
|
94
145
|
}
|
|
95
146
|
|
|
96
147
|
return {
|
|
@@ -106,6 +157,28 @@ export function normalizeCloudflaredTunnelConfig(rawConfig = {}, {
|
|
|
106
157
|
};
|
|
107
158
|
}
|
|
108
159
|
|
|
160
|
+
/**
|
|
161
|
+
* The tunnel as `oar doctor` reports it, from the config alone (no relay
|
|
162
|
+
* needed): whether the built-in tunnel is on and whether it could start.
|
|
163
|
+
*/
|
|
164
|
+
export function describeConfiguredTunnel(rawConfig = {}, {
|
|
165
|
+
env = process.env,
|
|
166
|
+
platform = process.platform,
|
|
167
|
+
configBaseDir = process.cwd(),
|
|
168
|
+
pathImpl = path,
|
|
169
|
+
locateBinary = locateCloudflaredBinary,
|
|
170
|
+
} = {}) {
|
|
171
|
+
const tunnelConfig = normalizeCloudflaredTunnelConfig(rawConfig, { env, configBaseDir, pathImpl });
|
|
172
|
+
if (tunnelConfig.mode !== 'managed') return 'disabled';
|
|
173
|
+
if (!tunnelConfig.valid) return `managed, cannot start: ${tunnelConfig.errors[0]}`;
|
|
174
|
+
const located = locateBinary(tunnelConfig.binary, { platform, env });
|
|
175
|
+
if (!located) {
|
|
176
|
+
const { binary, binarySource } = tunnelConfig;
|
|
177
|
+
return `managed, cannot start: ${describeMissingCloudflaredBinary({ binary, binarySource, platform })}`;
|
|
178
|
+
}
|
|
179
|
+
return `managed (cloudflared: ${located})`;
|
|
180
|
+
}
|
|
181
|
+
|
|
109
182
|
export function buildCloudflaredArgs(tunnelConfig) {
|
|
110
183
|
return ['tunnel', 'run', '--token', tunnelConfig.token, ...tunnelConfig.extraArgs];
|
|
111
184
|
}
|
|
@@ -145,12 +218,11 @@ export function createCloudflaredTunnelManager({
|
|
|
145
218
|
clearTimeoutImpl = clearTimeout,
|
|
146
219
|
configBaseDir = process.cwd(),
|
|
147
220
|
env = process.env,
|
|
148
|
-
|
|
221
|
+
locateBinary = locateCloudflaredBinary,
|
|
149
222
|
pathImpl = path,
|
|
150
223
|
} = {}) {
|
|
151
224
|
const tunnelConfig = normalizeCloudflaredTunnelConfig(rawTunnelConfig, {
|
|
152
225
|
env,
|
|
153
|
-
resolveBinary,
|
|
154
226
|
configBaseDir,
|
|
155
227
|
pathImpl,
|
|
156
228
|
});
|
|
@@ -174,6 +246,7 @@ export function createCloudflaredTunnelManager({
|
|
|
174
246
|
lastError: tunnelConfig.errors[0] || null,
|
|
175
247
|
lastEventAt: nowIso(),
|
|
176
248
|
binary: tunnelConfig.binary || null,
|
|
249
|
+
binaryMissing: false,
|
|
177
250
|
proc: null,
|
|
178
251
|
backoffTimer: null,
|
|
179
252
|
};
|
|
@@ -189,6 +262,7 @@ export function createCloudflaredTunnelManager({
|
|
|
189
262
|
reconnectAttempts: state.reconnectAttempts,
|
|
190
263
|
connectedSince: state.connectedSince,
|
|
191
264
|
lastError: state.lastError,
|
|
265
|
+
binaryMissing: state.binaryMissing,
|
|
192
266
|
});
|
|
193
267
|
};
|
|
194
268
|
|
|
@@ -220,9 +294,33 @@ export function createCloudflaredTunnelManager({
|
|
|
220
294
|
return;
|
|
221
295
|
}
|
|
222
296
|
|
|
297
|
+
// OAR ships no cloudflared. Without one the tunnel stays off, the state
|
|
298
|
+
// says how to install it, and a look every minute starts the tunnel once
|
|
299
|
+
// it is there (no relay restart, as long as it lands on the relay's PATH).
|
|
300
|
+
let located = null;
|
|
301
|
+
try { located = toText(locateBinary(tunnelConfig.binary, { platform, env })); } catch {}
|
|
302
|
+
if (!located) {
|
|
303
|
+
if (!state.binaryMissing) {
|
|
304
|
+
state.binaryMissing = true;
|
|
305
|
+
state.lastError = describeMissingCloudflaredBinary({
|
|
306
|
+
binary: tunnelConfig.binary,
|
|
307
|
+
binarySource: tunnelConfig.binarySource,
|
|
308
|
+
platform,
|
|
309
|
+
});
|
|
310
|
+
warn(`Tunnel not started: ${state.lastError}`);
|
|
311
|
+
updateBlockingState();
|
|
312
|
+
emitStatus();
|
|
313
|
+
}
|
|
314
|
+
if (state.backoffTimer) clearTimeoutImpl(state.backoffTimer);
|
|
315
|
+
state.backoffTimer = setTimeoutImpl(spawnTunnel, BINARY_RECHECK_MS);
|
|
316
|
+
if (typeof state.backoffTimer?.unref === 'function') state.backoffTimer.unref();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
state.binaryMissing = false;
|
|
320
|
+
|
|
223
321
|
const args = buildCloudflaredArgs(tunnelConfig);
|
|
224
|
-
log(`Spawning: ${
|
|
225
|
-
const proc = spawnImpl(
|
|
322
|
+
log(`Spawning: ${located} ${redactCloudflaredArgs(args).join(' ')}`);
|
|
323
|
+
const proc = spawnImpl(located, args, buildSpawnOptions(platform, ['ignore', 'pipe', 'pipe']));
|
|
226
324
|
state.proc = proc;
|
|
227
325
|
state.lastError = null;
|
|
228
326
|
updateBlockingState();
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Serving a file from disk so that what arrives is the file as it is now:
|
|
2
|
+
// one open, one fstat, and length, validators and bytes all from that handle.
|
|
3
|
+
// The headers tell every cache on the way, the browser's and a CDN's alike,
|
|
4
|
+
// to keep nothing.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
import { applySafeServedContentHeaders } from './safe-served-content.mjs';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A short token that changes whenever the file's bytes may have changed
|
|
13
|
+
* (modification time or size). It goes into download URLs as `v=…`: the
|
|
14
|
+
* server ignores it, and a changed file gets a URL nothing has seen before.
|
|
15
|
+
*/
|
|
16
|
+
export function fileVersionToken({ mtimeMs, size } = {}) {
|
|
17
|
+
const modified = Math.floor(Number(mtimeMs));
|
|
18
|
+
const bytes = Number(size);
|
|
19
|
+
if (!Number.isFinite(modified) || !Number.isFinite(bytes) || modified < 0 || bytes < 0) return '';
|
|
20
|
+
return `${modified.toString(36)}-${Math.floor(bytes).toString(36)}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `Cache-Control` speaks to browsers and to proxies that honour it. The two
|
|
25
|
+
* CDN headers bind the edge itself: Cloudflare obeys them ahead of
|
|
26
|
+
* `Cache-Control` and of a "cache everything" rule that would otherwise
|
|
27
|
+
* override the origin's wish.
|
|
28
|
+
*/
|
|
29
|
+
export function applyNoStoreHeaders(res) {
|
|
30
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
31
|
+
applyCdnNoStoreHeaders(res);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function applyCdnNoStoreHeaders(res) {
|
|
35
|
+
res.setHeader('CDN-Cache-Control', 'no-store');
|
|
36
|
+
res.setHeader('Cloudflare-CDN-Cache-Control', 'no-store');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The name a download is saved under, when the client asks for one (`as=…`).
|
|
41
|
+
* Accepted only as a plain file name with the real file's extension, so the
|
|
42
|
+
* request cannot change what kind of file the browser believes it gets.
|
|
43
|
+
*/
|
|
44
|
+
export function downloadNameOverride(requested, realName) {
|
|
45
|
+
const wanted = String(requested || '').trim();
|
|
46
|
+
if (!wanted || wanted.length > 200) return '';
|
|
47
|
+
if (/[\\/:*?"<>|\u0000-\u001f\u007f]/.test(wanted) || wanted === '.' || wanted === '..') return '';
|
|
48
|
+
const realExt = path.extname(String(realName || '')).toLowerCase();
|
|
49
|
+
if (path.extname(wanted).toLowerCase() !== realExt) return '';
|
|
50
|
+
return wanted;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function appendUrlParam(url, key, value) {
|
|
54
|
+
if (!value) return url;
|
|
55
|
+
return `${url}${url.includes('?') ? '&' : '?'}${key}=${encodeURIComponent(value)}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** `url` with the file's version token appended, or unchanged when there is none. */
|
|
59
|
+
export function withFileVersion(url, version) {
|
|
60
|
+
return appendUrlParam(String(url || ''), 'v', String(version || ''));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* `cacheControl` replaces the no-store default for content whose URL names
|
|
65
|
+
* its bytes (an upload addressed by its hash): such an answer may be kept.
|
|
66
|
+
*/
|
|
67
|
+
export function serveFileWithRangeSupport(req, res, filePath, meta, { safeName, cacheDelete = null, fsImpl = fs, cacheControl = '' } = {}) {
|
|
68
|
+
const realName = safeName || path.basename(filePath).replace(/"/g, '');
|
|
69
|
+
const name = downloadNameOverride(req.query?.as, realName) || realName;
|
|
70
|
+
const rangeHeader = req.headers['range'];
|
|
71
|
+
|
|
72
|
+
const fail = (error) => {
|
|
73
|
+
if (cacheDelete) cacheDelete(filePath);
|
|
74
|
+
if (res.headersSent) { res.destroy(error); return; }
|
|
75
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR' || error?.code === 'EISDIR') {
|
|
76
|
+
res.status(404).json({ error: 'File not found' });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
res.status(500).json({ error: 'Failed to read file' });
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// The caller's `meta` may be a moment old (it is cached). Size, validators
|
|
83
|
+
// and bytes all come from this one handle instead: a length taken from an
|
|
84
|
+
// older stat cut a file that had just grown down to its previous size.
|
|
85
|
+
let fd = null;
|
|
86
|
+
let stat = null;
|
|
87
|
+
try {
|
|
88
|
+
fd = fsImpl.openSync(filePath, 'r');
|
|
89
|
+
stat = fsImpl.fstatSync(fd);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (fd !== null) { try { fsImpl.closeSync(fd); } catch {} }
|
|
92
|
+
fail(error);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const closeFd = () => { try { fsImpl.closeSync(fd); } catch {} };
|
|
96
|
+
if (!stat.isFile()) {
|
|
97
|
+
closeFd();
|
|
98
|
+
fail(Object.assign(new Error('Not a file'), { code: 'EISDIR' }));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const fileSize = Number(stat.size || 0);
|
|
102
|
+
|
|
103
|
+
// From here until the stream owns the handle, any throw (a header value
|
|
104
|
+
// `setHeader` refuses, say) must close the handle, or every such request
|
|
105
|
+
// would leak one.
|
|
106
|
+
let start = 0;
|
|
107
|
+
let end = fileSize - 1;
|
|
108
|
+
try {
|
|
109
|
+
res.setHeader('Accept-Ranges', 'bytes');
|
|
110
|
+
if (cacheControl) res.setHeader('Cache-Control', cacheControl);
|
|
111
|
+
else applyNoStoreHeaders(res);
|
|
112
|
+
// Validators cost nothing next to no-store, and they let a client or a
|
|
113
|
+
// person with curl see which version of the file an answer carries.
|
|
114
|
+
const version = fileVersionToken({ mtimeMs: stat.mtimeMs, size: fileSize });
|
|
115
|
+
if (version) res.setHeader('ETag', `W/"${version}"`);
|
|
116
|
+
if (Number.isFinite(stat.mtimeMs)) res.setHeader('Last-Modified', new Date(stat.mtimeMs).toUTCString());
|
|
117
|
+
// Neutralize browser-executable types (HTML/SVG/XML), force nosniff, and
|
|
118
|
+
// sandbox the response so a worker-written workspace file can't run as script
|
|
119
|
+
// on this origin. Media stays inline for preview; everything else downloads.
|
|
120
|
+
applySafeServedContentHeaders(res, meta?.contentType, { fileName: name });
|
|
121
|
+
|
|
122
|
+
if (rangeHeader) {
|
|
123
|
+
const match = /bytes=(\d+)-(\d*)/.exec(rangeHeader);
|
|
124
|
+
const from = match ? parseInt(match[1], 10) : NaN;
|
|
125
|
+
const to = match && match[2] ? parseInt(match[2], 10) : fileSize - 1;
|
|
126
|
+
if (!match || from > to || from >= fileSize || to >= fileSize) {
|
|
127
|
+
closeFd();
|
|
128
|
+
res.setHeader('Content-Range', `bytes */${fileSize}`);
|
|
129
|
+
res.status(416).end();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
start = from;
|
|
133
|
+
end = to;
|
|
134
|
+
res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`);
|
|
135
|
+
res.status(206);
|
|
136
|
+
}
|
|
137
|
+
res.setHeader('Content-Length', String(Math.max(0, end - start + 1)));
|
|
138
|
+
} catch (error) {
|
|
139
|
+
closeFd();
|
|
140
|
+
fail(error);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (end - start + 1 <= 0) {
|
|
144
|
+
closeFd();
|
|
145
|
+
res.end();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
// Bounded to the size just read, so a file that grows while it is sent
|
|
149
|
+
// still yields exactly the announced number of bytes.
|
|
150
|
+
const stream = fsImpl.createReadStream(null, { fd, start, end, autoClose: true });
|
|
151
|
+
stream.on('error', fail);
|
|
152
|
+
// A client that goes away mid-download would otherwise leave the handle open.
|
|
153
|
+
res.on?.('close', () => stream.destroy());
|
|
154
|
+
stream.pipe(res);
|
|
155
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The list of pinned messages a conversation carries: one short plain-text
|
|
4
|
+
// preview per pin, so the browser can show the list without loading (or
|
|
5
|
+
// rendering markdown for) messages outside its history page.
|
|
6
|
+
|
|
7
|
+
import { stripRelayPromptContext } from './relay-prompt-sanitizer.mjs';
|
|
8
|
+
|
|
9
|
+
export const MESSAGE_PIN_LIMIT = 100;
|
|
10
|
+
export const PIN_PREVIEW_MAX_LENGTH = 160;
|
|
11
|
+
|
|
12
|
+
function countAttachments(raw) {
|
|
13
|
+
if (Array.isArray(raw)) return raw.length;
|
|
14
|
+
if (typeof raw !== 'string' || !raw) return 0;
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(raw);
|
|
17
|
+
return Array.isArray(parsed) ? parsed.length : 0;
|
|
18
|
+
} catch {
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Light on purpose: enough that a heading, a list or a link reads as a line of
|
|
24
|
+
// text. Single * and _ stay, they are more often part of a name than emphasis.
|
|
25
|
+
export function buildPinPreview(text, mode = '') {
|
|
26
|
+
const plain = String(stripRelayPromptContext(String(text || ''), mode) || '')
|
|
27
|
+
.replace(/```[^\n]*\n?/g, ' ')
|
|
28
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, (_match, alt) => (alt ? `🖼 ${alt}` : '🖼'))
|
|
29
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
30
|
+
.replace(/^\s{0,3}(?:#{1,6}|>+|[-*+]|\d+[.)])\s+/gm, '')
|
|
31
|
+
.replace(/\*\*|__|~~|`/g, '')
|
|
32
|
+
.replace(/\s+/g, ' ')
|
|
33
|
+
.trim();
|
|
34
|
+
// By code point, so an emoji at the cut is dropped whole.
|
|
35
|
+
const points = Array.from(plain);
|
|
36
|
+
if (points.length <= PIN_PREVIEW_MAX_LENGTH) return plain;
|
|
37
|
+
return `${points.slice(0, PIN_PREVIEW_MAX_LENGTH - 1).join('').trimEnd()}…`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildPinList(rows) {
|
|
41
|
+
return (Array.isArray(rows) ? rows : [])
|
|
42
|
+
.filter((row) => String(row?.id || '').trim() && row?.pinned_at)
|
|
43
|
+
.map((row) => ({
|
|
44
|
+
messageId: String(row.id).trim(),
|
|
45
|
+
role: String(row.role || '').trim(),
|
|
46
|
+
preview: buildPinPreview(row.text, row.mode),
|
|
47
|
+
timestamp: row.timestamp || null,
|
|
48
|
+
pinnedAt: row.pinned_at,
|
|
49
|
+
attachmentCount: countAttachments(row.attachments),
|
|
50
|
+
hiddenFromShares: Number(row.hidden_from_shares || 0) === 1,
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pure helpers behind `oar
|
|
2
|
+
* Pure helpers behind the `oar` command line — everything here is
|
|
3
3
|
* deterministic and unit-testable; the interactive glue stays in bin/oar.js.
|
|
4
4
|
*/
|
|
5
5
|
|
|
@@ -41,6 +41,135 @@ export function relayUrl({ config, lanAddress = null } = {}) {
|
|
|
41
41
|
return `http://${host}:${config?.port || 3333}/?token=${encodeURIComponent(token)}`;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* What `oar setup` and `oar url` print about the relay's address. `qrUrl` is
|
|
46
|
+
* set only for an address a phone can reach: the LAN address when LAN access
|
|
47
|
+
* is on, and never one that is internal to WSL. `tunnelUrl` is the address a
|
|
48
|
+
* running relay reports for its tunnel, with the token added.
|
|
49
|
+
*/
|
|
50
|
+
export function describeRelayAddress({ config, lanAddress = null, wslNat = false, tunnelBase = '' } = {}) {
|
|
51
|
+
const lan = config?.localhostOnly === false;
|
|
52
|
+
const url = relayUrl({ config: { ...config, localhostOnly: !lan }, lanAddress });
|
|
53
|
+
const lines = [`[oar] Relay URL: ${url}`];
|
|
54
|
+
let qrUrl = null;
|
|
55
|
+
if (!lan) {
|
|
56
|
+
lines.push('[oar] This machine only. For a phone, switch on LAN access (oar setup --lan) or use a tunnel (README: Remote access).');
|
|
57
|
+
} else if (!lanAddress) {
|
|
58
|
+
lines.push('[oar] LAN access is on, but this machine has no network address.');
|
|
59
|
+
} else if (wslNat) {
|
|
60
|
+
lines.push('[oar] That address is internal to WSL; other devices cannot reach it. Ways out: a tunnel (README: Remote access), or WSL\'s mirrored networking (networkingMode=mirrored in .wslconfig).');
|
|
61
|
+
} else {
|
|
62
|
+
qrUrl = url;
|
|
63
|
+
}
|
|
64
|
+
const base = String(tunnelBase || '').trim().replace(/\/+$/, '');
|
|
65
|
+
const tunnelUrl = base ? `${base}/?token=${encodeURIComponent(String(config?.authToken || '').trim())}` : null;
|
|
66
|
+
return { url, lines, qrUrl, tunnelUrl };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const VALUE_OPTIONS = ['--port', '--migrate-from', '--to'];
|
|
70
|
+
const LAUNCHER_FLAGS = ['--install-extension', '--no-install-extension', '--migrate-from'];
|
|
71
|
+
const COMMAND_OPTIONS = { '--setup': 'setup', '--start': 'start', '--stop': 'stop', '--status': 'status' };
|
|
72
|
+
const CLI_COMMANDS = {
|
|
73
|
+
menu: {},
|
|
74
|
+
start: {},
|
|
75
|
+
stop: {},
|
|
76
|
+
restart: {},
|
|
77
|
+
status: {},
|
|
78
|
+
url: {},
|
|
79
|
+
doctor: {},
|
|
80
|
+
help: {},
|
|
81
|
+
version: {},
|
|
82
|
+
setup: { flags: ['--lan', '--local', '--new-token', '--defaults', '--start'], values: ['--port', '--migrate-from'] },
|
|
83
|
+
service: { words: ['install', 'remove', 'status'] },
|
|
84
|
+
update: { flags: ['--beta'], values: ['--to'] },
|
|
85
|
+
copilot: { flags: ['--install-extension', '--no-install-extension'], values: ['--port', '--migrate-from'], forwards: true },
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export function usageText() {
|
|
89
|
+
return [
|
|
90
|
+
'Usage: oar [command] [options]',
|
|
91
|
+
'',
|
|
92
|
+
' oar Menu in a terminal; status and this text elsewhere',
|
|
93
|
+
' oar start Start the relay in the background',
|
|
94
|
+
' oar stop Stop the relay (waits for running turns)',
|
|
95
|
+
' oar restart Restart the relay (waits for running turns)',
|
|
96
|
+
' oar status Is the relay running, on which port, as a service',
|
|
97
|
+
' oar url Relay URL, with a QR code when a phone can reach it',
|
|
98
|
+
' oar setup Auth token, access and port; asks, or takes',
|
|
99
|
+
' [--port <port>] [--lan | --local] [--new-token] [--defaults] [--start]',
|
|
100
|
+
' oar service install | remove | status',
|
|
101
|
+
' Start the relay at login',
|
|
102
|
+
' oar copilot [--port <port>] [--install-extension] [--no-install-extension] [-- gh copilot args...]',
|
|
103
|
+
' Relay plus a Copilot terminal session',
|
|
104
|
+
' oar doctor Check the install and the provider CLIs',
|
|
105
|
+
' oar update [--beta] [--to <version>]',
|
|
106
|
+
' oar help This text',
|
|
107
|
+
' oar --version',
|
|
108
|
+
].join('\n');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Reads the command line into `{ command, args }`, or `{ error }` for anything
|
|
113
|
+
* `oar` does not know, so nothing starts by accident. The command may follow
|
|
114
|
+
* options (`oar --port 3339 setup`), and `--setup`, `--start`, `--stop` and
|
|
115
|
+
* `--status` name it too. Without a command the launcher's own flags (or a
|
|
116
|
+
* `--` part) mean the Copilot session, and nothing at all means the menu.
|
|
117
|
+
*/
|
|
118
|
+
export function parseCliArgs(argv = []) {
|
|
119
|
+
const all = (Array.isArray(argv) ? argv : []).map((arg) => String(arg));
|
|
120
|
+
const separator = all.indexOf('--');
|
|
121
|
+
const head = separator === -1 ? all : all.slice(0, separator);
|
|
122
|
+
const forwarded = separator === -1 ? [] : all.slice(separator);
|
|
123
|
+
if (head.includes('--help') || head.includes('-h')) return { command: 'help', args: [] };
|
|
124
|
+
|
|
125
|
+
let command = null;
|
|
126
|
+
let args = [];
|
|
127
|
+
for (let index = 0; index < head.length; index += 1) {
|
|
128
|
+
const arg = head[index];
|
|
129
|
+
if (VALUE_OPTIONS.includes(arg)) {
|
|
130
|
+
if (index + 1 >= head.length) return { error: `${arg} needs a value.` };
|
|
131
|
+
args.push(arg, head[index += 1]);
|
|
132
|
+
} else if (arg.startsWith('-') || command) {
|
|
133
|
+
args.push(arg);
|
|
134
|
+
} else {
|
|
135
|
+
command = arg;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (!command) {
|
|
139
|
+
const named = args.includes('--setup') ? '--setup' : args.find((arg) => COMMAND_OPTIONS[arg]);
|
|
140
|
+
if (args.includes('--version') || args.includes('-v')) return { command: 'version', args: [] };
|
|
141
|
+
if (named) {
|
|
142
|
+
command = COMMAND_OPTIONS[named];
|
|
143
|
+
args.splice(args.indexOf(named), 1);
|
|
144
|
+
} else if (forwarded.length || args.some((arg) => LAUNCHER_FLAGS.includes(arg))) {
|
|
145
|
+
command = 'copilot';
|
|
146
|
+
} else if (!args.length) {
|
|
147
|
+
command = 'menu';
|
|
148
|
+
} else {
|
|
149
|
+
// An option this command line knows needs its command; any other is unknown.
|
|
150
|
+
const known = [...VALUE_OPTIONS, '--lan', '--local', '--new-token', '--defaults', '--beta'];
|
|
151
|
+
return { error: known.includes(args[0]) ? `${args[0]} needs a command.` : `Unknown option: ${args[0]}` };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const spec = Object.hasOwn(CLI_COMMANDS, command) ? CLI_COMMANDS[command] : null;
|
|
156
|
+
if (!spec) return { error: `Unknown command: ${command}` };
|
|
157
|
+
if (forwarded.length && !spec.forwards) return { error: `oar ${command} takes no "--" part.` };
|
|
158
|
+
let words = 0;
|
|
159
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
160
|
+
const arg = args[index];
|
|
161
|
+
const name = arg.startsWith('--') ? arg.split('=')[0] : arg;
|
|
162
|
+
if (spec.values?.includes(name)) {
|
|
163
|
+
if (arg === name) index += 1;
|
|
164
|
+
} else if (spec.words?.includes(arg) && words === 0) {
|
|
165
|
+
words += 1;
|
|
166
|
+
} else if (!spec.flags?.includes(arg)) {
|
|
167
|
+
return { error: arg.startsWith('-') ? `Unknown option for oar ${command}: ${arg}` : `Unexpected argument: ${arg}` };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { command, args: [...args, ...forwarded] };
|
|
171
|
+
}
|
|
172
|
+
|
|
44
173
|
/**
|
|
45
174
|
* A systemd *user* unit for the relay server. The launcher is not used here —
|
|
46
175
|
* the service runs server.js directly with the same env the launcher would set,
|
|
@@ -102,7 +231,7 @@ export function buildServicePath({ nodeBin, envPath = '', homeDir = '' } = {}) {
|
|
|
102
231
|
* accept the flag and run the scripts as before. `npm-install-scripts.test.mjs`
|
|
103
232
|
* holds this list to the lockfile.
|
|
104
233
|
*/
|
|
105
|
-
export const NPM_INSTALL_SCRIPT_PACKAGES = Object.freeze(['better-sqlite3', '
|
|
234
|
+
export const NPM_INSTALL_SCRIPT_PACKAGES = Object.freeze(['better-sqlite3', 'koffi']);
|
|
106
235
|
export const NPM_ALLOW_SCRIPTS_ARG = `--allow-scripts=${NPM_INSTALL_SCRIPT_PACKAGES.join(',')}`;
|
|
107
236
|
|
|
108
237
|
/**
|
|
@@ -155,6 +284,8 @@ export function renderDoctorReport({
|
|
|
155
284
|
dbPath,
|
|
156
285
|
dbSizeBytes,
|
|
157
286
|
probes = [],
|
|
287
|
+
warnings = [],
|
|
288
|
+
tunnel = '',
|
|
158
289
|
}) {
|
|
159
290
|
const yesNo = (v) => (v ? 'yes' : 'no');
|
|
160
291
|
const lines = [
|
|
@@ -167,13 +298,17 @@ export function renderDoctorReport({
|
|
|
167
298
|
if (config) {
|
|
168
299
|
lines.push(` port : ${config.port ?? 3333} (localhostOnly: ${yesNo(config.localhostOnly !== false)})`);
|
|
169
300
|
lines.push(` auth token : ${String(config.authToken || '').trim() ? 'set' : 'MISSING'}`);
|
|
170
|
-
|
|
171
|
-
|
|
301
|
+
// `tunnel` is the caller's finding (describeConfiguredTunnel: is the tunnel
|
|
302
|
+
// on, and is there a cloudflared to run it); without one, the config's word.
|
|
303
|
+
const tunnelConfig = config.cloudflaredTunnel || {};
|
|
304
|
+
const configured = tunnelConfig.enabled === true || tunnelConfig.mode === 'managed' ? 'managed' : 'disabled';
|
|
305
|
+
lines.push(` tunnel : ${tunnel || configured}`);
|
|
172
306
|
}
|
|
173
307
|
lines.push(` database : ${dbSizeBytes !== null ? `${dbPath} (${(dbSizeBytes / 1048576).toFixed(1)} MB)` : `${dbPath} (not created yet)`}`);
|
|
174
308
|
lines.push(' provider CLIs :');
|
|
175
309
|
for (const probe of probes) {
|
|
176
310
|
lines.push(` ${probe.id.padEnd(14)}: ${probe.ok ? probe.version || 'installed' : 'not found'}`);
|
|
177
311
|
}
|
|
312
|
+
for (const warning of warnings) lines.push(` warning : ${warning}`);
|
|
178
313
|
return lines.join('\n');
|
|
179
314
|
}
|