@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
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal side of `oar`: line prompts and the menu. Everything reads
|
|
3
|
+
* from and writes to the stream pair it is given, so tests drive it with
|
|
4
|
+
* fake streams.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import readline from 'node:readline';
|
|
8
|
+
|
|
9
|
+
/** Ctrl+C (or a closed input) in a prompt or in the menu; `oar` exits 130 on it, without a message. */
|
|
10
|
+
export class PromptAborted extends Error {
|
|
11
|
+
constructor() {
|
|
12
|
+
super('Aborted');
|
|
13
|
+
this.code = 'OAR_PROMPT_ABORTED';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function askLine(question, { input = process.stdin, output = process.stdout } = {}) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
let rl;
|
|
20
|
+
try {
|
|
21
|
+
rl = readline.createInterface({ input, output, terminal: Boolean(input.isTTY) });
|
|
22
|
+
} catch {
|
|
23
|
+
// A terminal that refuses raw mode still reads whole lines.
|
|
24
|
+
rl = readline.createInterface({ input, output, terminal: false });
|
|
25
|
+
}
|
|
26
|
+
let settled = false;
|
|
27
|
+
const abort = () => {
|
|
28
|
+
if (settled) return;
|
|
29
|
+
settled = true;
|
|
30
|
+
rl.close();
|
|
31
|
+
output.write('\n');
|
|
32
|
+
reject(new PromptAborted());
|
|
33
|
+
};
|
|
34
|
+
rl.once('SIGINT', abort);
|
|
35
|
+
rl.once('close', abort);
|
|
36
|
+
rl.question(question, (answer) => {
|
|
37
|
+
settled = true;
|
|
38
|
+
rl.close();
|
|
39
|
+
resolve(String(answer).trim());
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function askYesNo(question, { defaultYes = false, input, output } = {}) {
|
|
45
|
+
const answer = (await askLine(`${question} ${defaultYes ? '[Y/n]' : '[y/N]'} `, { input, output })).toLowerCase();
|
|
46
|
+
return answer ? answer === 'y' || answer === 'yes' : defaultYes;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createPrompter({ input = process.stdin, output = process.stdout } = {}) {
|
|
50
|
+
return {
|
|
51
|
+
line: (question) => askLine(question, { input, output }),
|
|
52
|
+
yesNo: (question, options = {}) => askYesNo(question, { ...options, input, output }),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const MENU_ENTRIES = Object.freeze([
|
|
57
|
+
{ id: 'start', label: 'Start' },
|
|
58
|
+
{ id: 'stop', label: 'Stop' },
|
|
59
|
+
{ id: 'restart', label: 'Restart' },
|
|
60
|
+
{ id: 'url', label: 'Show URL and QR code' },
|
|
61
|
+
{ id: 'setup', label: 'Settings' },
|
|
62
|
+
{ id: 'service', label: 'Install or remove the service' },
|
|
63
|
+
{ id: 'update', label: 'Update' },
|
|
64
|
+
{ id: 'doctor', label: 'Doctor' },
|
|
65
|
+
{ id: 'copilot', label: 'Copilot session' },
|
|
66
|
+
{ id: 'quit', label: 'Quit' },
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
const RELAY_TEXT = {
|
|
70
|
+
running: 'relay running',
|
|
71
|
+
stopped: 'relay stopped',
|
|
72
|
+
taken: 'relay not reachable (its port is held by another program, or by a relay with other settings)',
|
|
73
|
+
unconfigured: 'not set up yet (choose Settings)',
|
|
74
|
+
};
|
|
75
|
+
const SERVICE_TEXT = {
|
|
76
|
+
active: 'installed, running',
|
|
77
|
+
installed: 'installed',
|
|
78
|
+
none: 'not installed',
|
|
79
|
+
unavailable: 'not available here',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export function menuUsesColor({ env = process.env, output = process.stdout } = {}) {
|
|
83
|
+
return Boolean(output?.isTTY) && !String(env?.NO_COLOR || '');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The menu as lines of text: the status header, then the entries, the selected one marked. */
|
|
87
|
+
export function renderMenu({ status = {}, selected = 0, color = false } = {}) {
|
|
88
|
+
const paint = (code, text) => (color ? `\x1b[${code}m${text}\x1b[0m` : text);
|
|
89
|
+
const relay = RELAY_TEXT[status.relay] || RELAY_TEXT.stopped;
|
|
90
|
+
const lines = [
|
|
91
|
+
`${paint(1, `OAR ${status.version || ''}`.trim())} · ${paint(status.relay === 'running' ? 32 : 31, relay)}`,
|
|
92
|
+
];
|
|
93
|
+
if (status.url) lines.push(` URL ${status.url}`);
|
|
94
|
+
if (status.port) lines.push(` Port ${status.port}`);
|
|
95
|
+
if (status.access) lines.push(` Access ${status.access === 'lan' ? 'LAN' : 'this machine only'}`);
|
|
96
|
+
if (SERVICE_TEXT[status.service]) lines.push(` Service ${SERVICE_TEXT[status.service]}`);
|
|
97
|
+
for (const warning of status.warnings || []) lines.push(paint(33, ` ! ${warning}`));
|
|
98
|
+
lines.push('');
|
|
99
|
+
MENU_ENTRIES.forEach((entry, index) => {
|
|
100
|
+
const key = entry.id === 'quit' ? 'q' : String(index + 1);
|
|
101
|
+
const text = `${index === selected ? '>' : ' '} ${key} ${entry.label}`;
|
|
102
|
+
lines.push(index === selected ? paint(7, text) : text);
|
|
103
|
+
});
|
|
104
|
+
lines.push('', 'Arrow keys and Enter, or a digit, to choose; q to quit.');
|
|
105
|
+
return lines;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** One chunk of raw input as a menu key: 'up', 'down', 'enter', 'quit', 'interrupt', an entry index, or null. */
|
|
109
|
+
export function readMenuKey(chunk) {
|
|
110
|
+
const key = String(chunk);
|
|
111
|
+
if (key === '\x03' || key === '\x04') return 'interrupt';
|
|
112
|
+
if (key === '\x1b[A' || key === '\x1bOA' || key === 'k') return 'up';
|
|
113
|
+
if (key === '\x1b[B' || key === '\x1bOB' || key === 'j') return 'down';
|
|
114
|
+
if (key === '\r' || key === '\n') return 'enter';
|
|
115
|
+
if (key === 'q' || key === 'Q') return 'quit';
|
|
116
|
+
if (/^[1-9]$/.test(key)) return Number(key) - 1;
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Reads raw key presses until `onKey` returns a value; the terminal is back in line mode on every way out. */
|
|
121
|
+
function readKeys({ input, onKey }) {
|
|
122
|
+
return new Promise((resolve, reject) => {
|
|
123
|
+
const finish = (settle, value) => {
|
|
124
|
+
input.removeListener('data', onData);
|
|
125
|
+
input.removeListener('end', onEnd);
|
|
126
|
+
try { input.setRawMode(false); } catch {}
|
|
127
|
+
input.pause();
|
|
128
|
+
settle(value);
|
|
129
|
+
};
|
|
130
|
+
const onEnd = () => finish(reject, new PromptAborted());
|
|
131
|
+
const onData = (chunk) => {
|
|
132
|
+
let result;
|
|
133
|
+
try { result = onKey(readMenuKey(chunk)); } catch (error) { finish(reject, error); return; }
|
|
134
|
+
if (result !== undefined) finish(resolve, result);
|
|
135
|
+
};
|
|
136
|
+
input.on('data', onData);
|
|
137
|
+
input.once('end', onEnd);
|
|
138
|
+
try {
|
|
139
|
+
input.setRawMode(true);
|
|
140
|
+
input.resume();
|
|
141
|
+
} catch (error) {
|
|
142
|
+
finish(reject, error);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Runs the menu until Quit or Ctrl+C. `loadStatus()` returns the header's
|
|
149
|
+
* status before each drawing; `actions[id](status)` runs an entry and may
|
|
150
|
+
* return `{ exit: true }` to leave the menu. An input without raw mode gets
|
|
151
|
+
* the same list with a numbered prompt.
|
|
152
|
+
*/
|
|
153
|
+
export async function runMenu({
|
|
154
|
+
input = process.stdin,
|
|
155
|
+
output = process.stdout,
|
|
156
|
+
env = process.env,
|
|
157
|
+
loadStatus = async () => ({}),
|
|
158
|
+
actions = {},
|
|
159
|
+
} = {}) {
|
|
160
|
+
const color = menuUsesColor({ env, output });
|
|
161
|
+
let rawMode = typeof input.setRawMode === 'function';
|
|
162
|
+
let selected = 0;
|
|
163
|
+
const draw = (status) => {
|
|
164
|
+
output.write(`${output.isTTY ? '\x1b[2J\x1b[H' : ''}${renderMenu({ status, selected, color }).join('\n')}\n`);
|
|
165
|
+
};
|
|
166
|
+
const pickByKey = (status) => readKeys({
|
|
167
|
+
input,
|
|
168
|
+
onKey: (key) => {
|
|
169
|
+
if (key === 'interrupt') throw new PromptAborted();
|
|
170
|
+
if (key === 'quit') return MENU_ENTRIES.length - 1;
|
|
171
|
+
if (key === 'enter') return selected;
|
|
172
|
+
if (typeof key === 'number') {
|
|
173
|
+
selected = key;
|
|
174
|
+
return selected;
|
|
175
|
+
}
|
|
176
|
+
if (key === 'up' || key === 'down') {
|
|
177
|
+
selected = (selected + (key === 'up' ? MENU_ENTRIES.length - 1 : 1)) % MENU_ENTRIES.length;
|
|
178
|
+
draw(status);
|
|
179
|
+
}
|
|
180
|
+
return undefined;
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
const pickByNumber = async () => {
|
|
184
|
+
for (;;) {
|
|
185
|
+
const answer = (await askLine('Choose (1-9, q): ', { input, output })).toLowerCase();
|
|
186
|
+
if (answer === 'q') return MENU_ENTRIES.length - 1;
|
|
187
|
+
if (/^[1-9]$/.test(answer)) return Number(answer) - 1;
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
for (;;) {
|
|
193
|
+
const status = await loadStatus();
|
|
194
|
+
draw(status);
|
|
195
|
+
let choice;
|
|
196
|
+
try {
|
|
197
|
+
choice = rawMode ? await pickByKey(status) : await pickByNumber();
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (error instanceof PromptAborted || !rawMode) throw error;
|
|
200
|
+
// The terminal refused raw mode.
|
|
201
|
+
rawMode = false;
|
|
202
|
+
choice = await pickByNumber();
|
|
203
|
+
}
|
|
204
|
+
selected = choice;
|
|
205
|
+
const entry = MENU_ENTRIES[choice];
|
|
206
|
+
if (entry.id === 'quit') return { code: 0, reason: 'menu-quit' };
|
|
207
|
+
output.write('\n');
|
|
208
|
+
let result = null;
|
|
209
|
+
try {
|
|
210
|
+
result = await actions[entry.id]?.(status);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
// Ctrl+C inside an entry (a setup question) leaves that entry, not
|
|
213
|
+
// the menu.
|
|
214
|
+
if (error instanceof PromptAborted) {
|
|
215
|
+
output.write('\n');
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
output.write(`[oar] ${error?.message || error}\n`);
|
|
219
|
+
}
|
|
220
|
+
if (result?.exit) return { code: result.code ?? 0, reason: 'menu-exit' };
|
|
221
|
+
output.write('\n');
|
|
222
|
+
if (rawMode) {
|
|
223
|
+
output.write('Press a key to go back to the menu. ');
|
|
224
|
+
await readKeys({
|
|
225
|
+
input,
|
|
226
|
+
onKey: (key) => {
|
|
227
|
+
if (key === 'interrupt') throw new PromptAborted();
|
|
228
|
+
return true;
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
} else {
|
|
232
|
+
await askLine('Press Enter to go back to the menu. ', { input, output });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (!(error instanceof PromptAborted)) throw error;
|
|
237
|
+
output.write('\n');
|
|
238
|
+
return { code: 130, reason: 'interrupted' };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `oar` needs to know about WSL. A distro in WSL's default (NAT)
|
|
3
|
+
* networking has its own network namespace: a port a Windows program listens
|
|
4
|
+
* on looks free inside the distro, and a Windows browser's `localhost:<port>`
|
|
5
|
+
* then reaches the Windows program, not the relay in the distro. Windows is
|
|
6
|
+
* asked through interop; every failure means "unknown" and is treated like a
|
|
7
|
+
* plain Linux host.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { spawnSync } from 'node:child_process';
|
|
13
|
+
|
|
14
|
+
const INTEROP_TIMEOUT_MS = 4000;
|
|
15
|
+
// WSL's own localhost forwarders listen on Windows for every port a distro
|
|
16
|
+
// listens on, so they stand for the distro's program, not for a Windows one.
|
|
17
|
+
const WSL_FORWARDER_RE = /^wsl(relay|host)\.exe$/i;
|
|
18
|
+
|
|
19
|
+
export function isWsl({ env = process.env, platform = process.platform, readFileImpl = fs.readFileSync } = {}) {
|
|
20
|
+
if (platform !== 'linux') return false;
|
|
21
|
+
if (String(env?.WSL_DISTRO_NAME || env?.WSL_INTEROP || '').trim()) return true;
|
|
22
|
+
try {
|
|
23
|
+
return /microsoft|wsl/i.test(String(readFileImpl('/proc/sys/kernel/osrelease', 'utf8')));
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The Windows system folder as the distro sees it: `[automount] root` of /etc/wsl.conf, else /mnt/. */
|
|
30
|
+
export function windowsSystemDir({ readFileImpl = fs.readFileSync } = {}) {
|
|
31
|
+
let root = '/mnt/';
|
|
32
|
+
try {
|
|
33
|
+
let section = '';
|
|
34
|
+
for (const raw of String(readFileImpl('/etc/wsl.conf', 'utf8')).split(/\r?\n/)) {
|
|
35
|
+
const line = raw.trim();
|
|
36
|
+
const header = line.match(/^\[(.+)\]$/);
|
|
37
|
+
if (header) section = header[1].trim().toLowerCase();
|
|
38
|
+
const pair = section === 'automount' && line.match(/^root\s*=\s*(.+)$/i);
|
|
39
|
+
if (pair) root = pair[1].trim().replace(/^["']|["']$/g, '') || root;
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// No wsl.conf: the default mount root.
|
|
43
|
+
}
|
|
44
|
+
return path.posix.join(root, 'c', 'Windows', 'System32');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function runWindowsTool(name, args, { spawnSyncImpl = spawnSync, readFileImpl = fs.readFileSync } = {}) {
|
|
48
|
+
try {
|
|
49
|
+
const result = spawnSyncImpl(path.posix.join(windowsSystemDir({ readFileImpl }), name), args, {
|
|
50
|
+
encoding: 'utf8',
|
|
51
|
+
timeout: INTEROP_TIMEOUT_MS,
|
|
52
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
53
|
+
});
|
|
54
|
+
return result && !result.error && result.status === 0 ? String(result.stdout || '') : null;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Listening TCP ports in `netstat -ano` output, each with the process ids
|
|
62
|
+
* that hold it. Windows prints the state word in the system language, so a
|
|
63
|
+
* listener is recognised by its shape instead: a TCP row whose remote address
|
|
64
|
+
* is the all-zero one (`0.0.0.0:0` or `[::]:0`). Only listeners a browser's
|
|
65
|
+
* `localhost` reaches count: the loopback and the any-address ones.
|
|
66
|
+
*/
|
|
67
|
+
export function parseNetstatListeners(text) {
|
|
68
|
+
const listeners = new Map();
|
|
69
|
+
for (const line of String(text || '').split(/\r?\n/)) {
|
|
70
|
+
const row = line.match(/^\s*TCP\s+(\S+):(\d+)\s+(?:0\.0\.0\.0|\[::\]):0\s+.*?(\d+)\s*$/);
|
|
71
|
+
if (!row || !['0.0.0.0', '127.0.0.1', '[::]', '[::1]'].includes(row[1])) continue;
|
|
72
|
+
const port = Number(row[2]);
|
|
73
|
+
const pids = listeners.get(port) || [];
|
|
74
|
+
if (!pids.includes(Number(row[3]))) pids.push(Number(row[3]));
|
|
75
|
+
listeners.set(port, pids);
|
|
76
|
+
}
|
|
77
|
+
return listeners;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Port → process ids of the Windows listeners, or null when Windows cannot be asked. */
|
|
81
|
+
export function windowsListeningPorts(options = {}) {
|
|
82
|
+
const output = runWindowsTool('netstat.exe', ['-ano'], options);
|
|
83
|
+
return output === null ? null : parseNetstatListeners(output);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function windowsProcessName(pid, options) {
|
|
87
|
+
const output = runWindowsTool('tasklist.exe', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], options);
|
|
88
|
+
return output?.match(/^"([^"]+)"/m)?.[1] || '';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A function `(port) => name | null`: the Windows program that holds the port,
|
|
93
|
+
* or null when none does, when this is not WSL, or when Windows cannot be
|
|
94
|
+
* asked. The listener list is read once, on the first call.
|
|
95
|
+
*/
|
|
96
|
+
export function createWindowsPortCheck(options = {}) {
|
|
97
|
+
if (!isWsl(options)) return () => null;
|
|
98
|
+
let listeners;
|
|
99
|
+
return (port) => {
|
|
100
|
+
if (listeners === undefined) listeners = windowsListeningPorts(options);
|
|
101
|
+
for (const pid of listeners?.get(Number(port)) || []) {
|
|
102
|
+
const name = windowsProcessName(pid, options);
|
|
103
|
+
if (!WSL_FORWARDER_RE.test(name)) return name || 'a Windows program';
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* True in a WSL distro whose network is its own (NAT): its LAN address is
|
|
111
|
+
* internal to WSL. Mirrored networking shares the addresses of Windows.
|
|
112
|
+
*/
|
|
113
|
+
export function isWslNat(options = {}) {
|
|
114
|
+
if (!isWsl(options)) return false;
|
|
115
|
+
try {
|
|
116
|
+
const result = (options.spawnSyncImpl || spawnSync)('wslinfo', ['--networking-mode'], {
|
|
117
|
+
encoding: 'utf8',
|
|
118
|
+
timeout: INTEROP_TIMEOUT_MS,
|
|
119
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
120
|
+
});
|
|
121
|
+
return String(result?.stdout || '').trim().toLowerCase() !== 'mirrored';
|
|
122
|
+
} catch {
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function windowsPortWarning(port, holder) {
|
|
128
|
+
return `Windows holds port ${port} (${holder}): a browser on Windows reaches that program, not this relay. Pick another port: oar setup --port N`;
|
|
129
|
+
}
|
|
@@ -3,15 +3,20 @@
|
|
|
3
3
|
//
|
|
4
4
|
// The threat: a semi-trusted producer (an AI worker writing a workspace file, or
|
|
5
5
|
// an uploaded attachment) can supply content whose MIME type the browser will
|
|
6
|
-
// execute as markup/script on THIS origin — `text/html`,
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
6
|
+
// execute as markup/script on THIS origin — `text/html`, XML with embedded
|
|
7
|
+
// script. Served inline, that is stored XSS against the app (and, via the
|
|
8
|
+
// unauthenticated /api/shared/* routes, against share viewers). We neutralize
|
|
9
|
+
// such types to `text/plain` and always send `nosniff`, and we attach a
|
|
10
|
+
// locked-down CSP + `sandbox` so anything that still reaches the browser as a
|
|
11
11
|
// document can neither run script nor navigate.
|
|
12
|
+
//
|
|
13
|
+
// SVG keeps its type: as an `<img>` it never runs script, and opened as a
|
|
14
|
+
// document the `sandbox` CSP denies it script and an origin, so the viewer
|
|
15
|
+
// and the file browser's thumbnails can show it.
|
|
12
16
|
|
|
13
17
|
// Types the browser renders as active documents on this origin.
|
|
14
|
-
const ACTIVE_MARKUP_PATTERN = /^text\/html\b|
|
|
18
|
+
const ACTIVE_MARKUP_PATTERN = /^text\/html\b|xhtml|(^|\/)xml\b|\+xml\b/i;
|
|
19
|
+
const SVG_TYPE = 'image/svg+xml';
|
|
15
20
|
|
|
16
21
|
// Types safe to preview inline with their real Content-Type.
|
|
17
22
|
const INLINE_SAFE_PREFIXES = ['image/', 'video/', 'audio/'];
|
|
@@ -25,7 +30,7 @@ const INLINE_SAFE_EXACT = new Set(['application/pdf']);
|
|
|
25
30
|
export function safeServedContentType(mimeType) {
|
|
26
31
|
const type = String(mimeType || '').trim().toLowerCase().split(';')[0].slice(0, 127)
|
|
27
32
|
|| 'application/octet-stream';
|
|
28
|
-
if (ACTIVE_MARKUP_PATTERN.test(type)) {
|
|
33
|
+
if (type !== SVG_TYPE && ACTIVE_MARKUP_PATTERN.test(type)) {
|
|
29
34
|
// Render markup/script content as its own source text instead of executing it.
|
|
30
35
|
return { contentType: 'text/plain; charset=utf-8', inlineSafe: false, neutralized: true };
|
|
31
36
|
}
|
|
@@ -34,6 +39,45 @@ export function safeServedContentType(mimeType) {
|
|
|
34
39
|
return { contentType: type, inlineSafe, neutralized: false };
|
|
35
40
|
}
|
|
36
41
|
|
|
42
|
+
// Printable ASCII, the only characters a quoted `filename="…"` may hold once
|
|
43
|
+
// quotes and backslashes are gone. Anything else goes into `filename*`.
|
|
44
|
+
const NON_ASCII_PATTERN = /[^\x20-\x7e]/g;
|
|
45
|
+
// A surrogate without its partner: not a character, and encodeURIComponent
|
|
46
|
+
// throws on it.
|
|
47
|
+
const LONE_SURROGATE_PATTERN = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
48
|
+
|
|
49
|
+
/** RFC 5987 `ext-value` encoding: UTF-8 percent-encoded, attr-char kept. */
|
|
50
|
+
function encodeRfc5987(value) {
|
|
51
|
+
return encodeURIComponent(value)
|
|
52
|
+
// encodeURIComponent leaves these alone, but RFC 5987 does not allow them bare.
|
|
53
|
+
.replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The `Content-Disposition` value for `fileName`, RFC 6266 style: a quoted
|
|
58
|
+
* `filename` every client can read, built from the name's ASCII characters
|
|
59
|
+
* (never empty: `file` plus the extension when nothing is left), and, when the
|
|
60
|
+
* name has characters outside ASCII, `filename*=UTF-8''…` carrying the whole
|
|
61
|
+
* name percent-encoded. The result holds nothing a header may not: no
|
|
62
|
+
* control characters, no quotes inside the quoted string, and no character
|
|
63
|
+
* outside Latin-1, which Node's `setHeader` rejects (and which used to make a
|
|
64
|
+
* file with a Japanese or emoji name unservable).
|
|
65
|
+
*/
|
|
66
|
+
export function contentDispositionValue(disposition, fileName) {
|
|
67
|
+
const name = String(fileName || '')
|
|
68
|
+
.replace(LONE_SURROGATE_PATTERN, '')
|
|
69
|
+
.replace(/[\u0000-\u001f\u007f"\\]/g, '')
|
|
70
|
+
.slice(0, 255);
|
|
71
|
+
if (!name) return disposition;
|
|
72
|
+
const ascii = name.replace(NON_ASCII_PATTERN, '').trim();
|
|
73
|
+
if (ascii === name) return `${disposition}; filename="${name}"`;
|
|
74
|
+
const dot = name.lastIndexOf('.');
|
|
75
|
+
const stem = (dot > 0 ? name.slice(0, dot) : name).replace(NON_ASCII_PATTERN, '').replace(/^[\s.]+|[\s.]+$/g, '') || 'file';
|
|
76
|
+
const ext = (dot > 0 ? name.slice(dot) : '').replace(NON_ASCII_PATTERN, '').trim();
|
|
77
|
+
const fallback = `${stem}${ext.length > 1 ? ext : ''}`;
|
|
78
|
+
return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encodeRfc5987(name)}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
37
81
|
/**
|
|
38
82
|
* Apply the safe Content-Type plus the hardening headers to a response that is
|
|
39
83
|
* about to stream stored bytes. Returns the resolved descriptor so callers can
|
|
@@ -46,12 +90,18 @@ export function applySafeServedContentHeaders(res, mimeType, { fileName = '' } =
|
|
|
46
90
|
// Belt-and-braces: a served file is never part of the app UI, so forbid it
|
|
47
91
|
// from loading anything or being framed, and sandbox it so it cannot run
|
|
48
92
|
// script or navigate even if a browser would otherwise treat it as active.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
93
|
+
// A PDF is the exception: browsers refuse to show a sandboxed PDF in their
|
|
94
|
+
// built-in viewer, so it gets no `sandbox` and may be framed by this origin.
|
|
95
|
+
// PDF viewers do not run document script, so the sandbox bought nothing there.
|
|
96
|
+
res.setHeader(
|
|
97
|
+
'Content-Security-Policy',
|
|
98
|
+
resolved.contentType === 'application/pdf'
|
|
99
|
+
? "default-src 'none'; frame-ancestors 'self'"
|
|
100
|
+
: "default-src 'none'; sandbox; frame-ancestors 'none'",
|
|
101
|
+
);
|
|
52
102
|
res.setHeader(
|
|
53
103
|
'Content-Disposition',
|
|
54
|
-
|
|
104
|
+
contentDispositionValue(resolved.inlineSafe ? 'inline' : 'attachment', fileName),
|
|
55
105
|
);
|
|
56
106
|
return resolved;
|
|
57
107
|
}
|
|
@@ -144,6 +144,22 @@ export function createSessionHistoryRefreshService({
|
|
|
144
144
|
WHERE conversation_id = ? AND hidden_from_shares = 1
|
|
145
145
|
`)
|
|
146
146
|
: null;
|
|
147
|
+
// Pins are carried across the replace the same way as hidden marks.
|
|
148
|
+
const messagesSupportPins = messageColumns.has('pinned_at');
|
|
149
|
+
const listPinnedMessageMarks = messagesSupportPins
|
|
150
|
+
? db.prepare(`
|
|
151
|
+
SELECT id, role, text, timestamp, pinned_at
|
|
152
|
+
FROM messages
|
|
153
|
+
WHERE conversation_id = ? AND pinned_at IS NOT NULL
|
|
154
|
+
`)
|
|
155
|
+
: null;
|
|
156
|
+
const restoreMessagePin = messagesSupportPins
|
|
157
|
+
? db.prepare(`
|
|
158
|
+
UPDATE messages
|
|
159
|
+
SET pinned_at = ?
|
|
160
|
+
WHERE conversation_id = ? AND id = ?
|
|
161
|
+
`)
|
|
162
|
+
: null;
|
|
147
163
|
const restoreHiddenMessageVisibility = messagesSupportShareVisibility
|
|
148
164
|
? db.prepare(`
|
|
149
165
|
UPDATE messages
|
|
@@ -418,6 +434,7 @@ export function createSessionHistoryRefreshService({
|
|
|
418
434
|
|
|
419
435
|
const replaceRetrievableHistoryTx = db.transaction((conversationId, messages = []) => {
|
|
420
436
|
const hiddenMessages = listHiddenMessageVisibility?.all(conversationId) || [];
|
|
437
|
+
const pinnedMessages = listPinnedMessageMarks?.all(conversationId) || [];
|
|
421
438
|
const attachmentCandidates = collectAttachmentCandidates(conversationId);
|
|
422
439
|
deleteConversationMessages.run(conversationId);
|
|
423
440
|
deleteConversationActivity.run(conversationId);
|
|
@@ -437,6 +454,13 @@ export function createSessionHistoryRefreshService({
|
|
|
437
454
|
resolvedId,
|
|
438
455
|
);
|
|
439
456
|
}
|
|
457
|
+
// Its own consumed set: a message can be both hidden and pinned.
|
|
458
|
+
const consumedPinnedIds = new Set();
|
|
459
|
+
for (const pinnedMessage of pinnedMessages) {
|
|
460
|
+
const resolvedId = resolveHiddenMessageId(pinnedMessage, rebuiltMessages, consumedPinnedIds);
|
|
461
|
+
if (!resolvedId) continue;
|
|
462
|
+
restoreMessagePin.run(pinnedMessage.pinned_at, conversationId, resolvedId);
|
|
463
|
+
}
|
|
440
464
|
});
|
|
441
465
|
|
|
442
466
|
function evaluateRefreshIdleState(conversationId) {
|