@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +231 -0
  2. package/README.md +53 -32
  3. package/bin/oar.js +814 -228
  4. package/package.json +4 -5
  5. package/server/README.md +20 -1
  6. package/server/db-schema.mjs +8 -0
  7. package/server/migrations/0008-message-pins.mjs +85 -0
  8. package/server/public/app/api-client.js +30 -0
  9. package/server/public/app/attachments-view.js +508 -125
  10. package/server/public/app/back-guard.mjs +58 -0
  11. package/server/public/app/bootstrap.js +46 -2
  12. package/server/public/app/conversation-view.js +110 -25
  13. package/server/public/app/external-link-policy.mjs +4 -1
  14. package/server/public/app/file-version.mjs +58 -0
  15. package/server/public/app/journal-view.js +16 -2
  16. package/server/public/app/pinned-messages-view.mjs +253 -0
  17. package/server/public/app/pwa-install.js +1 -1
  18. package/server/public/app/relay-dot-state.mjs +8 -1
  19. package/server/public/app/router.js +8 -2
  20. package/server/public/app/socket-handlers.js +6 -0
  21. package/server/public/app/store.js +6 -0
  22. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  23. package/server/public/app/viewer-controls-state.mjs +59 -0
  24. package/server/public/app/viewer-gallery.mjs +46 -0
  25. package/server/public/app/viewer-gestures.mjs +105 -0
  26. package/server/public/app/viewer-history.mjs +43 -0
  27. package/server/public/app/viewer-tap.mjs +112 -0
  28. package/server/public/index.html +304 -54
  29. package/server/repositories/message-pin-statements.mjs +39 -0
  30. package/server/repositories/message-repository.mjs +5 -0
  31. package/server/routes/messages-routes.mjs +33 -61
  32. package/server/routes/sessions-routes.mjs +92 -24
  33. package/server/server-runtime.mjs +29 -0
  34. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  35. package/server/services/file-serving.mjs +155 -0
  36. package/server/services/message-pin-list.mjs +52 -0
  37. package/server/services/oar-cli-helpers.mjs +139 -4
  38. package/server/services/oar-cli-menu.mjs +240 -0
  39. package/server/services/oar-cli-wsl.mjs +129 -0
  40. package/server/services/safe-served-content.mjs +61 -11
  41. 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`, `image/svg+xml`, XML
7
- // with embedded script. Served inline, that is stored XSS against the app (and,
8
- // via the unauthenticated /api/shared/* routes, against share viewers). We
9
- // neutralize such types to `text/plain` and always send `nosniff`, and we attach
10
- // a locked-down CSP + `sandbox` so anything that still reaches the browser as a
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|svg|xhtml|(^|\/)xml\b|\+xml\b/i;
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
- res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox; frame-ancestors 'none'");
50
- const safeName = String(fileName || '').replace(/[\r\n"\\]/g, '').slice(0, 255);
51
- const disposition = resolved.inlineSafe ? 'inline' : 'attachment';
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
- safeName ? `${disposition}; filename="${safeName}"` : disposition,
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) {