@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
|
@@ -5,7 +5,7 @@ import path from 'path';
|
|
|
5
5
|
import { randomUUID } from 'crypto';
|
|
6
6
|
import { stripRelayPromptContext } from '../services/relay-prompt-sanitizer.mjs';
|
|
7
7
|
import { resolveUploadMimeType } from '../services/mime-sniffer.mjs';
|
|
8
|
-
import {
|
|
8
|
+
import { fileVersionToken, serveFileWithRangeSupport, withFileVersion } from '../services/file-serving.mjs';
|
|
9
9
|
import {
|
|
10
10
|
shouldParkForRestart,
|
|
11
11
|
parkPendingQueueForRestart,
|
|
@@ -71,6 +71,11 @@ const STRANDED_SESSION_HEARTBEAT_FRESH_MS = 15_000;
|
|
|
71
71
|
const RELAY_QUESTION_FINALIZATION_HOLD_MS = 5_000;
|
|
72
72
|
const AUTO_MODEL_SENTINEL = 'auto';
|
|
73
73
|
const USAGE_FETCH_RETRY_DELAY_MS = 250;
|
|
74
|
+
// Preview kinds whose bytes travel in the preview JSON; media and PDF are
|
|
75
|
+
// fetched by the browser from the raw URL instead.
|
|
76
|
+
const TEXT_PREVIEW_KINDS = new Set(['markdown', 'code', 'text']);
|
|
77
|
+
// An upload's URL names its bytes (the sha256), so a kept answer stays right.
|
|
78
|
+
const UPLOAD_CONTENT_CACHE_CONTROL = 'private, max-age=31536000, immutable';
|
|
74
79
|
|
|
75
80
|
export function resolveOpenAIReasoningEffort(explicitReasoningEffort = '', model = '') {
|
|
76
81
|
const explicit = String(explicitReasoningEffort || '').trim().toLowerCase();
|
|
@@ -1610,57 +1615,6 @@ export function cleanupPersistedGeneratedImagesForAssistantResponse({
|
|
|
1610
1615
|
}
|
|
1611
1616
|
}
|
|
1612
1617
|
|
|
1613
|
-
function serveFileWithRangeSupport(req, res, filePath, meta, { safeName, cacheDelete = null } = {}) {
|
|
1614
|
-
const fileSize = meta.size;
|
|
1615
|
-
const name = safeName || path.basename(filePath).replace(/"/g, '');
|
|
1616
|
-
const rangeHeader = req.headers['range'];
|
|
1617
|
-
|
|
1618
|
-
res.setHeader('Accept-Ranges', 'bytes');
|
|
1619
|
-
res.setHeader('Cache-Control', 'no-store');
|
|
1620
|
-
// Neutralize browser-executable types (HTML/SVG/XML), force nosniff, and
|
|
1621
|
-
// sandbox the response so a worker-written workspace file can't run as script
|
|
1622
|
-
// on this origin. Media stays inline for preview; everything else downloads.
|
|
1623
|
-
applySafeServedContentHeaders(res, meta.contentType, { fileName: name });
|
|
1624
|
-
|
|
1625
|
-
const onStreamError = (error) => {
|
|
1626
|
-
if (cacheDelete) cacheDelete(filePath);
|
|
1627
|
-
if (res.headersSent) { res.destroy(error); return; }
|
|
1628
|
-
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
|
|
1629
|
-
res.status(404).json({ error: 'File not found' });
|
|
1630
|
-
return;
|
|
1631
|
-
}
|
|
1632
|
-
res.status(500).json({ error: 'Failed to read file' });
|
|
1633
|
-
};
|
|
1634
|
-
|
|
1635
|
-
if (rangeHeader) {
|
|
1636
|
-
const match = /bytes=(\d+)-(\d*)/.exec(rangeHeader);
|
|
1637
|
-
if (!match) {
|
|
1638
|
-
res.setHeader('Content-Range', `bytes */${fileSize}`);
|
|
1639
|
-
res.status(416).end();
|
|
1640
|
-
return;
|
|
1641
|
-
}
|
|
1642
|
-
const start = parseInt(match[1], 10);
|
|
1643
|
-
const end = match[2] ? parseInt(match[2], 10) : fileSize - 1;
|
|
1644
|
-
if (start > end || start >= fileSize || end >= fileSize) {
|
|
1645
|
-
res.setHeader('Content-Range', `bytes */${fileSize}`);
|
|
1646
|
-
res.status(416).end();
|
|
1647
|
-
return;
|
|
1648
|
-
}
|
|
1649
|
-
const chunkSize = end - start + 1;
|
|
1650
|
-
res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`);
|
|
1651
|
-
res.setHeader('Content-Length', String(chunkSize));
|
|
1652
|
-
res.status(206);
|
|
1653
|
-
const stream = fs.createReadStream(filePath, { start, end });
|
|
1654
|
-
stream.on('error', onStreamError);
|
|
1655
|
-
stream.pipe(res);
|
|
1656
|
-
} else {
|
|
1657
|
-
res.setHeader('Content-Length', String(fileSize));
|
|
1658
|
-
const stream = fs.createReadStream(filePath);
|
|
1659
|
-
stream.on('error', onStreamError);
|
|
1660
|
-
stream.pipe(res);
|
|
1661
|
-
}
|
|
1662
|
-
}
|
|
1663
|
-
|
|
1664
1618
|
// Structural sanitizer for the workflow-progress digest a Claude session
|
|
1665
1619
|
// worker attaches to its background-task rows (docs/plans/workflow-progress-tree.md,
|
|
1666
1620
|
// Phase 2). The relay never trusts worker JSON: strings are trimmed and
|
|
@@ -1769,6 +1723,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1769
1723
|
fetchLinuxDirectoryEntries,
|
|
1770
1724
|
mapLinuxDirectoryEntry,
|
|
1771
1725
|
readWorkspaceFileMeta,
|
|
1726
|
+
// The cache readWorkspaceFileMeta fills. The routes drop an entry when a
|
|
1727
|
+
// read shows it is out of date; the name was used here without ever
|
|
1728
|
+
// being handed in, so each of those paths threw a ReferenceError.
|
|
1729
|
+
workspaceFileMetaCache,
|
|
1772
1730
|
resolveWorkspaceFilePath,
|
|
1773
1731
|
normalizeWorkspaceRelativePath,
|
|
1774
1732
|
previewLanguageForWorkspaceFile,
|
|
@@ -3408,9 +3366,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3408
3366
|
if (!file) return res.status(404).json({ error: 'Not found' });
|
|
3409
3367
|
const filePath = uploadPathForSha(sha256);
|
|
3410
3368
|
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Missing file on disk' });
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3369
|
+
serveFileWithRangeSupport(req, res, filePath, { contentType: file.mime_type }, {
|
|
3370
|
+
safeName: String(file.name || file.file_name || '').replace(/"/g, ''),
|
|
3371
|
+
cacheControl: UPLOAD_CONTENT_CACHE_CONTROL,
|
|
3372
|
+
});
|
|
3414
3373
|
});
|
|
3415
3374
|
|
|
3416
3375
|
app.get('/api/generated-image/:conversationId/:messageId/:imageId/content', auth, (req, res) => {
|
|
@@ -3483,6 +3442,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3483
3442
|
const filePath = resolveWorkspaceFilePath(requestedPath, rootOverride);
|
|
3484
3443
|
if (!filePath || !normalizedPath) return res.status(400).json({ error: 'Invalid file path' });
|
|
3485
3444
|
|
|
3445
|
+
// The preview carries the version the download link is built from, so
|
|
3446
|
+
// it reads the file's metadata fresh, not from the short-lived cache.
|
|
3447
|
+
workspaceFileMetaCache.delete(filePath);
|
|
3486
3448
|
let meta = null;
|
|
3487
3449
|
try {
|
|
3488
3450
|
meta = readWorkspaceFileMeta(filePath);
|
|
@@ -3538,10 +3500,12 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3538
3500
|
size,
|
|
3539
3501
|
truncated,
|
|
3540
3502
|
previewBytes: contentBuffer.length,
|
|
3541
|
-
|
|
3503
|
+
mtimeMs: meta.mtimeMs ?? null,
|
|
3504
|
+
version: fileVersionToken(meta),
|
|
3505
|
+
rawUrl: withFileVersion(`${remotePath}/api/files/${normalizedWebPath.split('/').map((part) => encodeURIComponent(part)).join('/')}${scopeSuffix}`, fileVersionToken(meta)),
|
|
3542
3506
|
};
|
|
3543
3507
|
|
|
3544
|
-
if (kind
|
|
3508
|
+
if (TEXT_PREVIEW_KINDS.has(kind)) {
|
|
3545
3509
|
payload.content = contentBuffer.toString('utf8');
|
|
3546
3510
|
}
|
|
3547
3511
|
|
|
@@ -3917,6 +3881,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3917
3881
|
const filePath = normalizeLinuxAbsolutePath(requestedPath);
|
|
3918
3882
|
if (!filePath) return res.status(400).json({ error: 'Invalid Linux file path' });
|
|
3919
3883
|
|
|
3884
|
+
// Fresh metadata: the download link's version comes from this answer.
|
|
3885
|
+
workspaceFileMetaCache.delete(filePath);
|
|
3920
3886
|
let meta = null;
|
|
3921
3887
|
try {
|
|
3922
3888
|
meta = readWorkspaceFileMeta(filePath);
|
|
@@ -3969,10 +3935,12 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3969
3935
|
size,
|
|
3970
3936
|
truncated,
|
|
3971
3937
|
previewBytes: contentBuffer.length,
|
|
3972
|
-
|
|
3938
|
+
mtimeMs: meta.mtimeMs ?? null,
|
|
3939
|
+
version: fileVersionToken(meta),
|
|
3940
|
+
rawUrl: withFileVersion(`${remotePath}/api/drives/file?path=${encodeURIComponent(filePath)}`, fileVersionToken(meta)),
|
|
3973
3941
|
};
|
|
3974
3942
|
|
|
3975
|
-
if (kind
|
|
3943
|
+
if (TEXT_PREVIEW_KINDS.has(kind)) {
|
|
3976
3944
|
payload.content = contentBuffer.toString('utf8');
|
|
3977
3945
|
}
|
|
3978
3946
|
|
|
@@ -3989,6 +3957,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3989
3957
|
return res.status(400).json({ error: 'Invalid drive file path' });
|
|
3990
3958
|
}
|
|
3991
3959
|
|
|
3960
|
+
// Fresh metadata: the download link's version comes from this answer.
|
|
3961
|
+
workspaceFileMetaCache.delete(filePath);
|
|
3992
3962
|
let meta = null;
|
|
3993
3963
|
try {
|
|
3994
3964
|
meta = readWorkspaceFileMeta(filePath);
|
|
@@ -4043,10 +4013,12 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4043
4013
|
size,
|
|
4044
4014
|
truncated,
|
|
4045
4015
|
previewBytes: contentBuffer.length,
|
|
4046
|
-
|
|
4016
|
+
mtimeMs: meta.mtimeMs ?? null,
|
|
4017
|
+
version: fileVersionToken(meta),
|
|
4018
|
+
rawUrl: withFileVersion(`${remotePath}/api/drives/file?path=${encodeURIComponent(normalizedWebPath)}`, fileVersionToken(meta)),
|
|
4047
4019
|
};
|
|
4048
4020
|
|
|
4049
|
-
if (kind
|
|
4021
|
+
if (TEXT_PREVIEW_KINDS.has(kind)) {
|
|
4050
4022
|
payload.content = contentBuffer.toString('utf8');
|
|
4051
4023
|
}
|
|
4052
4024
|
|
|
@@ -7,8 +7,9 @@ import path from 'path';
|
|
|
7
7
|
import { fileURLToPath } from 'url';
|
|
8
8
|
import { randomUUID } from 'crypto';
|
|
9
9
|
import { createSdkSessionSyncService } from '../services/sdk-session-sync-service.mjs';
|
|
10
|
-
import {
|
|
10
|
+
import { serveFileWithRangeSupport } from '../services/file-serving.mjs';
|
|
11
11
|
import { stripRelayPromptContext } from '../services/relay-prompt-sanitizer.mjs';
|
|
12
|
+
import { MESSAGE_PIN_LIMIT, buildPinList } from '../services/message-pin-list.mjs';
|
|
12
13
|
import { persistConversationPreferences } from '../services/conversation-preferences-service.mjs';
|
|
13
14
|
import { parseAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
|
|
14
15
|
import { CLAUDE_ATTRIBUTION_MODES, DEFAULT_CLAUDE_ATTRIBUTION_MODE, claudeAttributionModelLabel, claudeAttributionTrailer, normalizeClaudeAttributionMode, resolveClaudeAttributionMode } from '../../shared/claude-attribution.mjs';
|
|
@@ -88,6 +89,9 @@ const SESSION_WORKER_STATUS_QUEUE_STATES = Object.freeze(['pending', 'processing
|
|
|
88
89
|
// Registry statuses that mean "this worker is currently running" (same triple
|
|
89
90
|
// as countActiveCliWorkers in server-runtime.mjs).
|
|
90
91
|
const SESSION_WORKER_ACTIVE_STATUSES = Object.freeze(['starting', 'ready', 'processing']);
|
|
92
|
+
// A shared attachment's URL names its bytes (sha256 or a fixed generated-image
|
|
93
|
+
// path), so a kept answer stays right.
|
|
94
|
+
const SHARED_CONTENT_CACHE_CONTROL = 'private, max-age=31536000, immutable';
|
|
91
95
|
|
|
92
96
|
function normalizeWorkerStatusText(value, fallback = null) {
|
|
93
97
|
const text = String(value || '').trim();
|
|
@@ -1834,6 +1838,7 @@ export function buildConversationMessages({
|
|
|
1834
1838
|
attachments: message?.attachments || [],
|
|
1835
1839
|
mode: message?.mode || undefined,
|
|
1836
1840
|
hiddenFromShares: Number(message?.hidden_from_shares || 0) === 1,
|
|
1841
|
+
pinned: !!message?.pinned_at,
|
|
1837
1842
|
timestamp: message?.timestamp,
|
|
1838
1843
|
sourceMessageId,
|
|
1839
1844
|
executedProvider: message?.executed_provider || message?.executedProvider || undefined,
|
|
@@ -2218,6 +2223,23 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2218
2223
|
AND (id = ? OR response_message_id = ?)
|
|
2219
2224
|
LIMIT 1
|
|
2220
2225
|
`);
|
|
2226
|
+
function listConversationPins(conversationId) {
|
|
2227
|
+
return buildPinList(stmts.listPinnedMessages?.all?.(conversationId) || []);
|
|
2228
|
+
}
|
|
2229
|
+
// Orders the pin lists a browser receives. A conversation load that left
|
|
2230
|
+
// before a pin change can arrive after the change's own socket event; with
|
|
2231
|
+
// the revision the browser can tell the older list from the newer one. In
|
|
2232
|
+
// memory only: a restart starts above every revision handed out before.
|
|
2233
|
+
const pinRevisionFloor = Date.now();
|
|
2234
|
+
const pinRevisionByConversation = new Map();
|
|
2235
|
+
function conversationPinRevision(conversationId) {
|
|
2236
|
+
return pinRevisionByConversation.get(conversationId) || pinRevisionFloor;
|
|
2237
|
+
}
|
|
2238
|
+
function bumpConversationPinRevision(conversationId) {
|
|
2239
|
+
const next = Math.max(Date.now(), conversationPinRevision(conversationId) + 1);
|
|
2240
|
+
pinRevisionByConversation.set(conversationId, next);
|
|
2241
|
+
return next;
|
|
2242
|
+
}
|
|
2221
2243
|
const listSessionWorkerQueueRows = db.prepare(`
|
|
2222
2244
|
SELECT id, conversation_id, runtime_session_id, owner_sdk_session_id, status
|
|
2223
2245
|
FROM queue
|
|
@@ -2712,7 +2734,9 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2712
2734
|
});
|
|
2713
2735
|
messages = messages.map((message) => {
|
|
2714
2736
|
const sourceMessageId = responseMessageToSourceId.get(String(message.id || '').trim()) || message.sourceMessageId || undefined;
|
|
2715
|
-
|
|
2737
|
+
// Pins are the owner's bookmarks: a public share never learns of them.
|
|
2738
|
+
const { pinned: _pinned, ...unpinnedMessage } = message;
|
|
2739
|
+
const withSource = sourceMessageId ? { ...unpinnedMessage, sourceMessageId } : unpinnedMessage;
|
|
2716
2740
|
// A public share never shows another relay's address or ids.
|
|
2717
2741
|
const nextMessage = withSource.origin
|
|
2718
2742
|
? { ...withSource, origin: publicRemoteRelayOrigin(withSource.origin) }
|
|
@@ -3600,6 +3624,9 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3600
3624
|
cloud: conversationCloudPayload(conv, runtimeSession),
|
|
3601
3625
|
messages: history.messages,
|
|
3602
3626
|
pageInfo: history.pageInfo,
|
|
3627
|
+
// A re-import can move a pin onto a new message id.
|
|
3628
|
+
pins: listConversationPins(conversationId),
|
|
3629
|
+
pinsRevision: conversationPinRevision(conversationId),
|
|
3603
3630
|
refreshed: true,
|
|
3604
3631
|
});
|
|
3605
3632
|
});
|
|
@@ -3777,20 +3804,14 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3777
3804
|
if (!file) return res.status(404).json({ error: 'Shared attachment not found' });
|
|
3778
3805
|
const filePath = uploadPathForSha(sha256);
|
|
3779
3806
|
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Missing file on disk' });
|
|
3780
|
-
// Unauthenticated share viewers:
|
|
3781
|
-
//
|
|
3782
|
-
|
|
3807
|
+
// Unauthenticated share viewers: the serving helper neutralizes executable
|
|
3808
|
+
// types (HTML/SVG) and forces nosniff + sandbox so an attachment can't run
|
|
3809
|
+
// as script on this origin; ranges let a shared video seek.
|
|
3783
3810
|
res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
if (!res.headersSent) {
|
|
3788
|
-
res.status(500).json({ error: 'Failed to stream shared attachment' });
|
|
3789
|
-
return;
|
|
3790
|
-
}
|
|
3791
|
-
res.destroy();
|
|
3811
|
+
serveFileWithRangeSupport(req, res, filePath, { contentType: file.mime_type }, {
|
|
3812
|
+
safeName: String(file.name || file.file_name || '').replace(/"/g, ''),
|
|
3813
|
+
cacheControl: SHARED_CONTENT_CACHE_CONTROL,
|
|
3792
3814
|
});
|
|
3793
|
-
stream.pipe(res);
|
|
3794
3815
|
});
|
|
3795
3816
|
|
|
3796
3817
|
app.get('/api/shared/:token/generated-image/:messageId/:imageId/content', (req, res) => {
|
|
@@ -3832,18 +3853,10 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3832
3853
|
return res.status(404).json({ error: 'Shared attachment not found' });
|
|
3833
3854
|
}
|
|
3834
3855
|
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Shared attachment not found' });
|
|
3835
|
-
res.setHeader('Cache-Control', 'private, max-age=31536000, immutable');
|
|
3836
3856
|
res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
stream.on('error', () => {
|
|
3840
|
-
if (!res.headersSent) {
|
|
3841
|
-
res.status(500).json({ error: 'Failed to stream shared attachment' });
|
|
3842
|
-
return;
|
|
3843
|
-
}
|
|
3844
|
-
res.destroy();
|
|
3857
|
+
serveFileWithRangeSupport(req, res, filePath, { contentType: attachment.type }, {
|
|
3858
|
+
cacheControl: SHARED_CONTENT_CACHE_CONTROL,
|
|
3845
3859
|
});
|
|
3846
|
-
stream.pipe(res);
|
|
3847
3860
|
});
|
|
3848
3861
|
|
|
3849
3862
|
// GET /api/conversation/:id — get paginated conversation history
|
|
@@ -4000,6 +4013,9 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4000
4013
|
backgroundTasks: backgroundTaskStore?.get?.(resolvedConversationId) || [],
|
|
4001
4014
|
usageLimitPause: usageLimitPauseService?.getPause?.(resolvedConversationId) || null,
|
|
4002
4015
|
previews: listConversationPreviews?.(resolvedConversationId) || [],
|
|
4016
|
+
// Every pin of the conversation, also those outside this history page.
|
|
4017
|
+
pins: listConversationPins(resolvedConversationId),
|
|
4018
|
+
pinsRevision: conversationPinRevision(resolvedConversationId),
|
|
4003
4019
|
preferredRelayMode: preferences.preferredRelayMode,
|
|
4004
4020
|
preferredModel: preferences.preferredModel,
|
|
4005
4021
|
preferredReasoningEffort: preferences.preferredReasoningEffort,
|
|
@@ -4059,6 +4075,57 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4059
4075
|
});
|
|
4060
4076
|
});
|
|
4061
4077
|
|
|
4078
|
+
// Pin or unpin one message. Unlike share visibility this may change while
|
|
4079
|
+
// the message's turn runs (nothing public depends on it), and every open
|
|
4080
|
+
// client learns of it through the socket instead of reloading.
|
|
4081
|
+
app.patch('/api/conversation/:id/message/:messageId/pin', auth, (req, res) => {
|
|
4082
|
+
const conversationId = String(req.params.id || '').trim();
|
|
4083
|
+
const messageId = String(req.params.messageId || '').trim();
|
|
4084
|
+
if (!conversationId || !messageId) {
|
|
4085
|
+
return res.status(400).json({ error: 'Missing conversation or message id' });
|
|
4086
|
+
}
|
|
4087
|
+
if (typeof req.body?.pinned !== 'boolean') {
|
|
4088
|
+
return res.status(400).json({ error: 'pinned must be a boolean' });
|
|
4089
|
+
}
|
|
4090
|
+
|
|
4091
|
+
const conv = stmts.getConvAnyStatus.get(conversationId);
|
|
4092
|
+
if (!conv || String(conv.status || '').trim().toLowerCase() === 'deleted') {
|
|
4093
|
+
return res.status(404).json({ error: 'Conversation not found' });
|
|
4094
|
+
}
|
|
4095
|
+
const message = stmts.getMessageByConversation?.get(messageId, conversationId) || null;
|
|
4096
|
+
if (!message) return res.status(404).json({ error: 'Message not found' });
|
|
4097
|
+
if (!stmts.setMessagePinnedAt || !stmts.countPinnedMessages) {
|
|
4098
|
+
return res.status(503).json({ error: 'Pinning messages is unavailable until the relay restarts' });
|
|
4099
|
+
}
|
|
4100
|
+
|
|
4101
|
+
const pinned = req.body.pinned === true;
|
|
4102
|
+
const currentlyPinned = !!message.pinned_at;
|
|
4103
|
+
if (currentlyPinned !== pinned) {
|
|
4104
|
+
if (pinned && Number(stmts.countPinnedMessages.get(conversationId)?.cnt || 0) >= MESSAGE_PIN_LIMIT) {
|
|
4105
|
+
return res.status(409).json({
|
|
4106
|
+
error: `This conversation already has ${MESSAGE_PIN_LIMIT} pinned messages. Unpin one first.`,
|
|
4107
|
+
code: 'pin-limit',
|
|
4108
|
+
limit: MESSAGE_PIN_LIMIT,
|
|
4109
|
+
});
|
|
4110
|
+
}
|
|
4111
|
+
stmts.setMessagePinnedAt.run(pinned ? new Date().toISOString() : null, messageId, conversationId);
|
|
4112
|
+
}
|
|
4113
|
+
|
|
4114
|
+
const pins = listConversationPins(conversationId);
|
|
4115
|
+
if (currentlyPinned !== pinned) {
|
|
4116
|
+
const revision = bumpConversationPinRevision(conversationId);
|
|
4117
|
+
io?.emit?.('conversation_pins_updated', { conversationId, pins, revision });
|
|
4118
|
+
}
|
|
4119
|
+
return res.json({
|
|
4120
|
+
ok: true,
|
|
4121
|
+
conversationId,
|
|
4122
|
+
messageId,
|
|
4123
|
+
pinned,
|
|
4124
|
+
pins,
|
|
4125
|
+
pinsRevision: conversationPinRevision(conversationId),
|
|
4126
|
+
});
|
|
4127
|
+
});
|
|
4128
|
+
|
|
4062
4129
|
app.patch('/api/conversation/:id', auth, (req, res) => {
|
|
4063
4130
|
const conversationId = String(req.params.id || '').trim();
|
|
4064
4131
|
const result = persistConversationTitle({
|
|
@@ -5461,6 +5528,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
5461
5528
|
reconnectAttempts: runtimeState.cloudflaredTunnelState?.reconnectAttempts ?? 0,
|
|
5462
5529
|
connectedSince: runtimeState.cloudflaredTunnelState?.connectedSince ?? null,
|
|
5463
5530
|
lastError: runtimeState.cloudflaredTunnelState?.lastError ?? null,
|
|
5531
|
+
binaryMissing: runtimeState.cloudflaredTunnelState?.binaryMissing ?? false,
|
|
5464
5532
|
valid: runtimeState.cloudflaredTunnelState?.valid ?? true,
|
|
5465
5533
|
},
|
|
5466
5534
|
workerWebSocket: runtimeState.workerWebSocketStatus || null,
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
normalizeWorkspaceRootKey,
|
|
25
25
|
} from './services/workspace-root-path-policy.mjs';
|
|
26
26
|
import { stopSessionWorkerProcesses } from './services/session-worker-stop-service.mjs';
|
|
27
|
+
import { applyCdnNoStoreHeaders } from './services/file-serving.mjs';
|
|
27
28
|
import { pickLiveTurnRowId } from './services/live-turn-picker.mjs';
|
|
28
29
|
import { buildSteerSettleFailure, steerAgentLabelForProvider } from '../shared/steer-settle-failure.mjs';
|
|
29
30
|
import { sanitizeRelayQuestionContext as sanitizeRelayQuestionContextWith } from './services/relay-question-context.mjs';
|
|
@@ -1510,6 +1511,17 @@ const WORKSPACE_CONTENT_TYPES = Object.freeze({
|
|
|
1510
1511
|
'.gif': 'image/gif',
|
|
1511
1512
|
'.webp': 'image/webp',
|
|
1512
1513
|
'.ico': 'image/x-icon',
|
|
1514
|
+
'.bmp': 'image/bmp',
|
|
1515
|
+
'.avif': 'image/avif',
|
|
1516
|
+
'.mp3': 'audio/mpeg',
|
|
1517
|
+
'.wav': 'audio/wav',
|
|
1518
|
+
'.m4a': 'audio/mp4',
|
|
1519
|
+
'.aac': 'audio/aac',
|
|
1520
|
+
'.ogg': 'audio/ogg',
|
|
1521
|
+
'.oga': 'audio/ogg',
|
|
1522
|
+
'.opus': 'audio/ogg',
|
|
1523
|
+
'.flac': 'audio/flac',
|
|
1524
|
+
'.weba': 'audio/webm',
|
|
1513
1525
|
'.mp4': 'video/mp4',
|
|
1514
1526
|
'.m4v': 'video/mp4',
|
|
1515
1527
|
'.mov': 'video/quicktime',
|
|
@@ -1650,6 +1662,7 @@ const WORKSPACE_CODE_EXTENSIONS = new Set(Object.keys(WORKSPACE_PREVIEW_LANGUAGE
|
|
|
1650
1662
|
.filter((ext) => !WORKSPACE_MARKDOWN_EXTENSIONS.has(ext)));
|
|
1651
1663
|
const WORKSPACE_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.bmp', '.avif']);
|
|
1652
1664
|
const WORKSPACE_VIDEO_EXTENSIONS = new Set(['.mp4', '.m4v', '.mov', '.webm', '.mkv', '.avi', '.ogv', '.wmv']);
|
|
1665
|
+
const WORKSPACE_AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.m4a', '.aac', '.ogg', '.oga', '.opus', '.flac', '.weba']);
|
|
1653
1666
|
const REPO_HEAVY_DIR_NAMES = new Set(['.git', 'node_modules']);
|
|
1654
1667
|
const MAX_REPO_TREE_NODES = 20_000;
|
|
1655
1668
|
const MAX_REPO_TREE_DEPTH = 64;
|
|
@@ -4148,6 +4161,7 @@ function readWorkspaceFileMeta(filePath) {
|
|
|
4148
4161
|
return cacheWorkspaceFileMeta(filePath, {
|
|
4149
4162
|
kind: 'file',
|
|
4150
4163
|
size: Number(stat.size || 0),
|
|
4164
|
+
mtimeMs: Number(stat.mtimeMs || 0),
|
|
4151
4165
|
contentType: workspaceContentType(filePath),
|
|
4152
4166
|
});
|
|
4153
4167
|
}
|
|
@@ -4214,6 +4228,8 @@ function workspacePreviewKindForMeta(filePath, contentType) {
|
|
|
4214
4228
|
if (WORKSPACE_MARKDOWN_EXTENSIONS.has(normalizedExt)) return 'markdown';
|
|
4215
4229
|
if (WORKSPACE_IMAGE_EXTENSIONS.has(normalizedExt) || normalizedType.startsWith('image/')) return 'image';
|
|
4216
4230
|
if (WORKSPACE_VIDEO_EXTENSIONS.has(normalizedExt) || normalizedType.startsWith('video/')) return 'video';
|
|
4231
|
+
if (WORKSPACE_AUDIO_EXTENSIONS.has(normalizedExt) || normalizedType.startsWith('audio/')) return 'audio';
|
|
4232
|
+
if (normalizedExt === '.pdf' || normalizedType === 'application/pdf') return 'pdf';
|
|
4217
4233
|
if (WORKSPACE_CODE_EXTENSIONS.has(normalizedExt) || WORKSPACE_PREVIEW_LANGUAGE_BY_FILENAME[filename]) return 'code';
|
|
4218
4234
|
if (isLikelyTextContentType(normalizedType)) return 'text';
|
|
4219
4235
|
return 'binary';
|
|
@@ -6341,6 +6357,10 @@ app.use((req, _res, next) => {
|
|
|
6341
6357
|
stripRequestPathPrefix(req, remotePath);
|
|
6342
6358
|
next();
|
|
6343
6359
|
});
|
|
6360
|
+
function isApiRequestPath(requestPath) {
|
|
6361
|
+
return /^\/api(?:\/|$)/.test(String(requestPath || ''));
|
|
6362
|
+
}
|
|
6363
|
+
|
|
6344
6364
|
// Baseline security headers on every response. Served-file/attachment endpoints
|
|
6345
6365
|
// layer a stricter sandbox CSP on top (see applySafeServedContentHeaders). A
|
|
6346
6366
|
// global script-src CSP is intentionally omitted for now: the SPA relies on
|
|
@@ -6349,6 +6369,14 @@ app.use((req, res, next) => {
|
|
|
6349
6369
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
6350
6370
|
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
6351
6371
|
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
6372
|
+
// Nothing this relay answers belongs in a CDN's cache: it is all behind a
|
|
6373
|
+
// login. These two headers bind the edge itself, whatever cache rule the
|
|
6374
|
+
// zone in front of a tunnel has.
|
|
6375
|
+
applyCdnNoStoreHeaders(res);
|
|
6376
|
+
// An API answer is never stored by default, errors included (a 404 or a
|
|
6377
|
+
// 401 for a file must not outlive the file's absence). A route that
|
|
6378
|
+
// serves content-addressed bytes sets its own, longer-lived value.
|
|
6379
|
+
if (isApiRequestPath(req.path)) res.setHeader('Cache-Control', 'no-store');
|
|
6352
6380
|
next();
|
|
6353
6381
|
});
|
|
6354
6382
|
app.get('/socket.io/socket.io.js', (req, res, next) => {
|
|
@@ -6552,6 +6580,7 @@ const sharedRouteDeps = {
|
|
|
6552
6580
|
compareRepoDirEntries,
|
|
6553
6581
|
shouldSkipRepoEntryName,
|
|
6554
6582
|
readWorkspaceFileMeta,
|
|
6583
|
+
workspaceFileMetaCache,
|
|
6555
6584
|
resolveWorkspaceFilePath,
|
|
6556
6585
|
normalizeWorkspaceRelativePath,
|
|
6557
6586
|
previewLanguageForWorkspaceFile,
|