@oar-sh/oar 0.9.24 → 0.9.26
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/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
- package/CHANGELOG.md +284 -0
- package/README.md +12 -11
- package/package.json +5 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +15 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +8 -4
- package/server/claude-worker/claude-session-process.mjs +4 -0
- package/server/claude-worker/claude-turn-publisher.mjs +25 -11
- package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
- package/server/copilot-worker/copilot-prompt-context.mjs +2 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +11 -9
- package/server/cursor-worker/cursor-turn-runner.mjs +3 -1
- package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/grok-turn-runner.mjs +3 -1
- package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
- package/server/migrations/0039-media-index.mjs +167 -0
- package/server/migrations/0040-image-session-defaults.mjs +93 -0
- package/server/public/app/api-client.js +58 -0
- package/server/public/app/ask-user-view.js +2 -1
- package/server/public/app/attachments-view.js +66 -9
- package/server/public/app/bootstrap.js +85 -25
- package/server/public/app/chat-actions-menu-state.mjs +1 -0
- package/server/public/app/context-media-tab.js +143 -0
- package/server/public/app/context-usage-view.mjs +2 -2
- package/server/public/app/conversation-view.js +90 -5
- package/server/public/app/image-composer-ui.js +518 -0
- package/server/public/app/image-turn-placeholder.mjs +233 -0
- package/server/public/app/journal-view.js +12 -2
- package/server/public/app/live-activity-cap.mjs +3 -3
- package/server/public/app/markdown-math.mjs +471 -0
- package/server/public/app/media-gallery-model.mjs +202 -0
- package/server/public/app/media-gallery-ui.js +524 -0
- package/server/public/app/media-view.js +79 -0
- package/server/public/app/openai-image-composer.mjs +492 -0
- package/server/public/app/openai-image-cost-ui.js +426 -0
- package/server/public/app/openai-image-spend.mjs +443 -0
- package/server/public/app/plan-usage-view.mjs +38 -6
- package/server/public/app/pwa-install.js +25 -4
- package/server/public/app/relay-error-ctas.mjs +5 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +3 -0
- package/server/public/app/relay-restart-ui.js +2 -2
- package/server/public/app/remote-relays-settings.js +33 -6
- package/server/public/app/router.js +49 -237
- package/server/public/app/session-history-ui.js +2 -0
- package/server/public/app/settings-modal.js +4 -0
- package/server/public/app/settings-tabs.js +10 -1
- package/server/public/app/socket-handlers.js +26 -0
- package/server/public/app/store.js +2 -0
- package/server/public/app/stream-view-preference.mjs +1 -1
- package/server/public/app/tab-strip-fade.mjs +1 -1
- package/server/public/app/thoughts-preference.mjs +1 -1
- package/server/public/app/tmux-inspector-view.js +29 -16
- package/server/public/app/viewer-gallery.mjs +23 -1
- package/server/public/index.html +517 -123
- package/server/relay-tools.md +1 -0
- package/server/repositories/image-conversation-repository.mjs +69 -0
- package/server/repositories/media-repository.mjs +305 -0
- package/server/routes/media-routes.mjs +361 -0
- package/server/routes/messages-routes.mjs +1410 -360
- package/server/routes/relay-board-routes.mjs +46 -1
- package/server/routes/remote-relay-routes.mjs +1 -1
- package/server/routes/sessions-routes.mjs +361 -14
- package/server/server-runtime.mjs +21 -0
- package/server/services/database-file.mjs +6 -4
- package/server/services/generated-image-cleanup-service.mjs +2 -1
- package/server/services/generated-image-files.mjs +110 -0
- package/server/services/image-input-paths.mjs +389 -0
- package/server/services/image-operation-service.mjs +6 -2
- package/server/services/media-extract.mjs +653 -0
- package/server/services/media-index-service.mjs +221 -0
- package/server/services/openai-image-preview-turns.mjs +330 -0
- package/server/services/openai-image-spend-service.mjs +516 -0
- package/server/services/openai-image-stream.mjs +170 -0
- package/server/services/plan-usage-openai-image.mjs +148 -0
- package/server/services/relay-prompt-sanitizer.mjs +7 -0
- package/server/services/relay-restart-queue.mjs +1 -1
- package/server/services/remote-relay-caller-context.mjs +6 -0
- package/server/services/remote-relay-dispatcher.mjs +409 -23
- package/server/services/remote-relay-registry-service.mjs +10 -2
- package/server/services/session-worker-stop-service.mjs +50 -18
- package/server/services/token-usage-service.mjs +15 -0
- package/shared/image-header.mjs +90 -0
- package/shared/math-instructions.mjs +30 -0
- package/shared/openai-image-options.mjs +299 -0
- package/shared/openai-image-presets.mjs +175 -0
- package/shared/openai-image-previews.mjs +40 -0
- package/shared/openai-image-prices.mjs +291 -0
- package/shared/openai-image-spend.mjs +90 -0
- package/shared/remote-relay-contract.mjs +126 -4
- package/shared/worker-runtime/direct-openai-image.mjs +96 -50
- package/server/public/app/math-dollars.mjs +0 -89
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
|
|
5
|
+
import { createMediaRepository, decodeMediaCursor, encodeMediaCursor, MEDIA_SOURCES } from '../repositories/media-repository.mjs';
|
|
6
|
+
import { fileVersionToken, serveFileWithRangeSupport } from '../services/file-serving.mjs';
|
|
7
|
+
import { DATA_IMAGE_MAGIC as IMAGE_MAGIC, findDataImage, isPseudoFileSystemPath } from '../services/media-extract.mjs';
|
|
8
|
+
import { sniffMimeType } from '../services/mime-sniffer.mjs';
|
|
9
|
+
import { createMediaIndexService } from '../services/media-index-service.mjs';
|
|
10
|
+
import { applySafeServedContentHeaders } from '../services/safe-served-content.mjs';
|
|
11
|
+
|
|
12
|
+
// The media gallery: the 🧠 Media tab of a session and the global Media view.
|
|
13
|
+
// Owner only: every route sits behind `auth`; there is no shared-view or
|
|
14
|
+
// remote-relay counterpart. Files are served by index id only, so a file
|
|
15
|
+
// reaches the gallery only when a message really referenced it, and its
|
|
16
|
+
// stored path is checked again on every request.
|
|
17
|
+
|
|
18
|
+
const CONTENT_ADDRESSED_CACHE = 'private, max-age=31536000, immutable';
|
|
19
|
+
const REVALIDATE_CACHE = 'private, no-cache';
|
|
20
|
+
const DRIVES_TTL_MS = 60_000;
|
|
21
|
+
const MAX_PAGE_ROUNDS = 5;
|
|
22
|
+
// A file system call on a stored path that takes longer (an offline network
|
|
23
|
+
// drive) counts as a missing file.
|
|
24
|
+
const FS_TIMEOUT_MS = 1500;
|
|
25
|
+
const SERVABLE_TTL_MS = 60_000;
|
|
26
|
+
const SERVABLE_CACHE_MAX = 5000;
|
|
27
|
+
// The JS realpath, not the native one: on Windows the native one turns a
|
|
28
|
+
// mapped drive letter into its UNC path, which the drive check refuses.
|
|
29
|
+
const realpathKeepingDrives = promisify(fs.realpath);
|
|
30
|
+
const openFile = promisify(fs.open);
|
|
31
|
+
const fstatFile = promisify(fs.fstat);
|
|
32
|
+
const readFile = promisify(fs.read);
|
|
33
|
+
const closeFile = (fd) => fs.close(fd, () => {});
|
|
34
|
+
// O_NOFOLLOW where the platform has it: the checked path cannot become a link
|
|
35
|
+
// between the check and the open.
|
|
36
|
+
const OPEN_FLAGS = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
|
|
37
|
+
|
|
38
|
+
/** The promise's value, or allback after ms; onLate(value) gets a value that came too late. */
|
|
39
|
+
function withTimeout(promise, ms, fallback, onLate = () => {}) {
|
|
40
|
+
let timedOut = false;
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
const timer = setTimeout(() => {
|
|
43
|
+
timedOut = true;
|
|
44
|
+
resolve(fallback);
|
|
45
|
+
}, ms);
|
|
46
|
+
timer.unref?.();
|
|
47
|
+
Promise.resolve(promise).then((value) => {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
if (timedOut) onLate(value);
|
|
50
|
+
else resolve(value);
|
|
51
|
+
}, () => {
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
if (!timedOut) resolve(fallback);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The type to serve a local picture or video as, from its first bytes; '' when
|
|
60
|
+
* the bytes are not the kind its name promised (a text file renamed .png, an
|
|
61
|
+
* executable named .mp4): such a file is not served at all.
|
|
62
|
+
*/
|
|
63
|
+
export function sniffLocalMediaType(head, { mediaKind, mime }) {
|
|
64
|
+
const sniffed = sniffMimeType(head);
|
|
65
|
+
if (mediaKind === 'image') {
|
|
66
|
+
if (sniffed.startsWith('image/')) return sniffed;
|
|
67
|
+
if (mime === 'image/svg+xml') {
|
|
68
|
+
const text = head.toString('utf8').replace(/^/, '').trimStart().slice(0, 256).toLowerCase();
|
|
69
|
+
if (text.startsWith('<svg') || (text.startsWith('<?xml') && text.includes('<svg'))) return 'image/svg+xml';
|
|
70
|
+
}
|
|
71
|
+
return '';
|
|
72
|
+
}
|
|
73
|
+
if (mediaKind === 'video') {
|
|
74
|
+
if (sniffed.startsWith('video/')) return sniffed;
|
|
75
|
+
if (head.length > 4 && head[0] === 0x1a && head[1] === 0x45 && head[2] === 0xdf && head[3] === 0xa3) return 'video/webm';
|
|
76
|
+
if (head.length > 8 && head.subarray(4, 8).toString('latin1') === 'ftyp') return mime || 'video/mp4';
|
|
77
|
+
return '';
|
|
78
|
+
}
|
|
79
|
+
return '';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function readHead(fd, length = 512) {
|
|
83
|
+
const buffer = Buffer.alloc(length);
|
|
84
|
+
const { bytesRead } = await readFile(fd, buffer, 0, length, 0);
|
|
85
|
+
return buffer.subarray(0, bytesRead);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseGeneratedLocator(locator) {
|
|
89
|
+
try {
|
|
90
|
+
const value = JSON.parse(locator);
|
|
91
|
+
return value && typeof value === 'object' ? value : {};
|
|
92
|
+
} catch {
|
|
93
|
+
return {};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function generatedFromMessage(attachments, imageId) {
|
|
98
|
+
let list = [];
|
|
99
|
+
try {
|
|
100
|
+
list = JSON.parse(attachments || '[]');
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
if (!Array.isArray(list)) return null;
|
|
105
|
+
const entry = list.find((item) => item?.generatedImage && String(item.generatedImage.imageId || '') === String(imageId || ''));
|
|
106
|
+
return entry?.generatedImage || null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function registerMediaRoutes(app, {
|
|
110
|
+
auth,
|
|
111
|
+
db,
|
|
112
|
+
uploadPathForSha,
|
|
113
|
+
fetchBrowsableDrives = null,
|
|
114
|
+
resolveGeneratedImagePath = () => null,
|
|
115
|
+
platform = process.platform,
|
|
116
|
+
repository = createMediaRepository(db),
|
|
117
|
+
indexService = createMediaIndexService({ db, repository, platform }),
|
|
118
|
+
startIndexer = true,
|
|
119
|
+
now = () => Date.now(),
|
|
120
|
+
} = {}) {
|
|
121
|
+
let drivesCache = null;
|
|
122
|
+
|
|
123
|
+
// Windows: the drive roots a stored path may live on, cached a minute
|
|
124
|
+
// (listing them starts a process).
|
|
125
|
+
function allowedDriveRoots() {
|
|
126
|
+
if (platform !== 'win32' || typeof fetchBrowsableDrives !== 'function') return Promise.resolve(null);
|
|
127
|
+
if (drivesCache && now() - drivesCache.at < DRIVES_TTL_MS) return Promise.resolve(drivesCache.roots);
|
|
128
|
+
return new Promise((resolve) => {
|
|
129
|
+
fetchBrowsableDrives((error, drives) => {
|
|
130
|
+
if (error || !Array.isArray(drives)) return resolve(new Set());
|
|
131
|
+
const roots = new Set(drives.map((drive) => String(drive.rootAbsolute || '').toUpperCase()).filter(Boolean));
|
|
132
|
+
drivesCache = { at: now(), roots };
|
|
133
|
+
resolve(roots);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The stored path when it is still a path this relay serves; '' otherwise. Never decodes. */
|
|
139
|
+
async function servableLocalPath(locator) {
|
|
140
|
+
const value = String(locator || '');
|
|
141
|
+
if (!value || value.includes('\0')) return '';
|
|
142
|
+
if (platform === 'win32') {
|
|
143
|
+
if (!/^[A-Z]:\\/.test(value) || path.win32.normalize(value) !== value) return '';
|
|
144
|
+
const roots = await allowedDriveRoots();
|
|
145
|
+
if (roots && !roots.has(value.slice(0, 3).toUpperCase())) return '';
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
if (!value.startsWith('/') || path.posix.normalize(value) !== value) return '';
|
|
149
|
+
if (isPseudoFileSystemPath(value, platform)) return '';
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function generatedPath(item) {
|
|
154
|
+
let info = parseGeneratedLocator(item.locator);
|
|
155
|
+
if (!info.relativePath || !info.sessionId) {
|
|
156
|
+
// Embedded by its route only: the attachment of the message it names.
|
|
157
|
+
const message = info.messageId ? db.prepare(`SELECT attachments FROM messages WHERE id = ? AND conversation_id = ?`)
|
|
158
|
+
.get(info.messageId, info.conversationId) : null;
|
|
159
|
+
info = { ...info, ...(generatedFromMessage(message?.attachments, info.imageId) || {}) };
|
|
160
|
+
}
|
|
161
|
+
if (!info.relativePath || !info.sessionId) return '';
|
|
162
|
+
return resolveGeneratedImagePath({ sessionId: info.sessionId, relativePath: info.relativePath }) || '';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The file behind an item, for the presence check and for serving; null when it has none (remote, data). */
|
|
166
|
+
async function filePathFor(item) {
|
|
167
|
+
switch (item.locatorKind) {
|
|
168
|
+
case 'local_path': return (await servableLocalPath(item.locator)) || '';
|
|
169
|
+
case 'upload_sha': return /^[a-f0-9]{64}$/.test(item.locator) ? String(uploadPathForSha(item.locator) || '') : '';
|
|
170
|
+
case 'generated': return generatedPath(item);
|
|
171
|
+
default: return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function clientItem(item) {
|
|
176
|
+
const out = {
|
|
177
|
+
id: item.id,
|
|
178
|
+
conversationId: item.conversationId,
|
|
179
|
+
conversationTitle: item.conversationTitle,
|
|
180
|
+
archived: item.archived,
|
|
181
|
+
provider: item.provider,
|
|
182
|
+
messageId: item.messageId,
|
|
183
|
+
sender: item.sender,
|
|
184
|
+
origin: item.origin,
|
|
185
|
+
mediaKind: item.mediaKind,
|
|
186
|
+
mime: item.mime,
|
|
187
|
+
name: item.name,
|
|
188
|
+
alt: item.alt,
|
|
189
|
+
sizeBytes: item.sizeBytes,
|
|
190
|
+
timestamp: item.timestamp,
|
|
191
|
+
occurrences: item.occurrences,
|
|
192
|
+
contentUrl: null,
|
|
193
|
+
remoteUrl: null,
|
|
194
|
+
path: null,
|
|
195
|
+
};
|
|
196
|
+
if (item.locatorKind === 'remote_url') out.remoteUrl = item.locator;
|
|
197
|
+
else if (item.locatorKind === 'upload_sha') out.contentUrl = `/api/upload/${item.locator}/content`;
|
|
198
|
+
else out.contentUrl = `/api/media/${item.id}/content`;
|
|
199
|
+
if (item.locatorKind === 'local_path') out.path = item.locator;
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Opens the file of an item when it may be served: the stored path passes
|
|
205
|
+
* the checks, so does its real path (a link is followed only to a target
|
|
206
|
+
* that passes them too), it is a regular file, and a local picture or video
|
|
207
|
+
* is what its name says. One descriptor from the open on: the checks, the
|
|
208
|
+
* first bytes and the bytes sent are of the same file. `null` when it may
|
|
209
|
+
* not be served (or a call on the drive took too long); the caller closes
|
|
210
|
+
* `fd`.
|
|
211
|
+
*/
|
|
212
|
+
async function openServable(item) {
|
|
213
|
+
let filePath = await filePathFor(item);
|
|
214
|
+
if (!filePath) return null;
|
|
215
|
+
if (item.locatorKind === 'local_path') {
|
|
216
|
+
const real = await withTimeout(realpathKeepingDrives(filePath), FS_TIMEOUT_MS, '');
|
|
217
|
+
filePath = real ? await servableLocalPath(real) : '';
|
|
218
|
+
if (!filePath) return null;
|
|
219
|
+
}
|
|
220
|
+
const fd = await withTimeout(openFile(filePath, OPEN_FLAGS), FS_TIMEOUT_MS, null, closeFile);
|
|
221
|
+
if (fd === null || fd === undefined) return null;
|
|
222
|
+
try {
|
|
223
|
+
const stat = await withTimeout(fstatFile(fd), FS_TIMEOUT_MS, null);
|
|
224
|
+
if (!stat?.isFile()) throw new Error('not a file');
|
|
225
|
+
let contentType = item.mime || 'application/octet-stream';
|
|
226
|
+
if (item.locatorKind === 'local_path' && item.category === 'media') {
|
|
227
|
+
contentType = sniffLocalMediaType(await readHead(fd), item);
|
|
228
|
+
if (!contentType) throw new Error('not a picture or video');
|
|
229
|
+
}
|
|
230
|
+
return { fd, filePath, stat, contentType, contentAddressed: item.locatorKind !== 'local_path' };
|
|
231
|
+
} catch {
|
|
232
|
+
closeFile(fd);
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Whether a tile can be served, asked for every listed item with a file;
|
|
238
|
+
// kept a minute per item and file version of the stored path.
|
|
239
|
+
const servable = new Map();
|
|
240
|
+
async function present(item) {
|
|
241
|
+
if (item.locatorKind === 'remote_url') return true;
|
|
242
|
+
if (item.locatorKind === 'data_uri' || item.locatorKind === 'legacy_data_url') return true;
|
|
243
|
+
const key = `${item.id}:${item.locator}`;
|
|
244
|
+
const cached = servable.get(key);
|
|
245
|
+
if (cached && now() - cached.at < SERVABLE_TTL_MS) return cached.ok;
|
|
246
|
+
const opened = await openServable(item);
|
|
247
|
+
if (opened) closeFile(opened.fd);
|
|
248
|
+
servable.delete(key);
|
|
249
|
+
servable.set(key, { ok: Boolean(opened), at: now() });
|
|
250
|
+
while (servable.size > SERVABLE_CACHE_MAX) servable.delete(servable.keys().next().value);
|
|
251
|
+
return Boolean(opened);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function readFilters(query) {
|
|
255
|
+
const source = MEDIA_SOURCES.includes(String(query.source || '')) ? String(query.source) : 'all';
|
|
256
|
+
return {
|
|
257
|
+
category: query.category === 'file' ? 'file' : 'media',
|
|
258
|
+
conversationId: String(query.conversationId || '').trim() || null,
|
|
259
|
+
source,
|
|
260
|
+
provider: String(query.provider || '').trim() || null,
|
|
261
|
+
archived: query.archived === 'hide' ? 'hide' : 'include',
|
|
262
|
+
q: String(query.q || '').trim(),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
app.get('/api/media', auth, async (req, res) => {
|
|
267
|
+
try {
|
|
268
|
+
const indexing = indexService.drainForRequest();
|
|
269
|
+
const filters = readFilters(req.query || {});
|
|
270
|
+
const limit = Math.max(1, Math.min(120, Number.parseInt(String(req.query?.limit || '60'), 10) || 60));
|
|
271
|
+
let cursor = decodeMediaCursor(req.query?.cursor);
|
|
272
|
+
if (req.query?.cursor && !cursor) return res.status(400).json({ error: 'Invalid cursor' });
|
|
273
|
+
const items = [];
|
|
274
|
+
let missingHidden = 0;
|
|
275
|
+
let nextCursor = null;
|
|
276
|
+
// Missing files are left out; the page refills from the next rows.
|
|
277
|
+
for (let round = 0; round < MAX_PAGE_ROUNDS && items.length < limit; round += 1) {
|
|
278
|
+
const rows = repository.listMedia({ ...filters, cursor, limit });
|
|
279
|
+
for (const row of rows) {
|
|
280
|
+
if (items.length >= limit) break;
|
|
281
|
+
if (await present(row)) items.push(clientItem(row));
|
|
282
|
+
else missingHidden += 1;
|
|
283
|
+
cursor = { timestamp: row.timestamp, id: row.id };
|
|
284
|
+
}
|
|
285
|
+
nextCursor = rows.length === limit ? encodeMediaCursor(cursor) : null;
|
|
286
|
+
if (!nextCursor) break;
|
|
287
|
+
}
|
|
288
|
+
res.json({ items, nextCursor, missingHidden, indexing: { pending: indexing.pending } });
|
|
289
|
+
} catch (error) {
|
|
290
|
+
res.status(500).json({ error: error?.message || 'Failed to list media' });
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
app.get('/api/media/counts', auth, (req, res) => {
|
|
295
|
+
try {
|
|
296
|
+
const indexing = indexService.drainForRequest();
|
|
297
|
+
const conversationId = String(req.query?.conversationId || '').trim() || null;
|
|
298
|
+
res.json({ ...repository.countMedia({ conversationId }), pending: indexing.pending });
|
|
299
|
+
} catch (error) {
|
|
300
|
+
res.status(500).json({ error: error?.message || 'Failed to count media' });
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
app.get('/api/media/facets', auth, (req, res) => {
|
|
305
|
+
try {
|
|
306
|
+
indexService.drainForRequest();
|
|
307
|
+
res.json(repository.mediaFacets());
|
|
308
|
+
} catch (error) {
|
|
309
|
+
res.status(500).json({ error: error?.message || 'Failed to list media filters' });
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
app.get('/api/media/:id/content', auth, async (req, res) => {
|
|
314
|
+
const raw = String(req.params.id || '');
|
|
315
|
+
if (!/^\d{1,15}$/.test(raw)) return res.status(400).json({ error: 'Invalid media id' });
|
|
316
|
+
const item = repository.getMediaItem(Number(raw));
|
|
317
|
+
if (!item) return res.status(404).json({ error: 'Not found' });
|
|
318
|
+
try {
|
|
319
|
+
if (item.locatorKind === 'data_uri' || item.locatorKind === 'legacy_data_url') {
|
|
320
|
+
const found = findDataImage({ text: item.text, attachments: item.attachments }, item.identityKey);
|
|
321
|
+
const bytes = found ? Buffer.from(found.payload, 'base64') : null;
|
|
322
|
+
if (!bytes || !IMAGE_MAGIC[found.mime]?.(bytes)) return res.status(404).json({ error: 'Not found' });
|
|
323
|
+
const etag = `"${item.identityKey.slice('data:'.length, 'data:'.length + 32)}"`;
|
|
324
|
+
res.setHeader('Cache-Control', CONTENT_ADDRESSED_CACHE);
|
|
325
|
+
res.setHeader('ETag', etag);
|
|
326
|
+
if (req.headers['if-none-match'] === etag) return res.status(304).end();
|
|
327
|
+
applySafeServedContentHeaders(res, found.mime, { fileName: item.name || 'image' });
|
|
328
|
+
res.setHeader('Content-Length', String(bytes.length));
|
|
329
|
+
return res.end(bytes);
|
|
330
|
+
}
|
|
331
|
+
const opened = await openServable(item);
|
|
332
|
+
if (!opened) return res.status(404).json({ error: 'Not found' });
|
|
333
|
+
const { fd, filePath, stat, contentType, contentAddressed } = opened;
|
|
334
|
+
const version = fileVersionToken({ mtimeMs: stat.mtimeMs, size: stat.size });
|
|
335
|
+
if (!contentAddressed && version && req.headers['if-none-match'] === `W/"${version}"`) {
|
|
336
|
+
closeFile(fd);
|
|
337
|
+
res.setHeader('Cache-Control', REVALIDATE_CACHE);
|
|
338
|
+
res.setHeader('ETag', `W/"${version}"`);
|
|
339
|
+
return res.status(304).end();
|
|
340
|
+
}
|
|
341
|
+
const safeName = String(item.name || path.basename(filePath)).replace(/"/g, '');
|
|
342
|
+
// Sent from the descriptor the checks were made on.
|
|
343
|
+
const fromOpenFile = {
|
|
344
|
+
openSync: () => fd,
|
|
345
|
+
fstatSync: (handle) => fs.fstatSync(handle),
|
|
346
|
+
closeSync: (handle) => fs.closeSync(handle),
|
|
347
|
+
createReadStream: (target, options) => fs.createReadStream(target, options),
|
|
348
|
+
};
|
|
349
|
+
return serveFileWithRangeSupport(req, res, filePath, { contentType }, {
|
|
350
|
+
safeName,
|
|
351
|
+
cacheControl: contentAddressed ? CONTENT_ADDRESSED_CACHE : REVALIDATE_CACHE,
|
|
352
|
+
fsImpl: fromOpenFile,
|
|
353
|
+
});
|
|
354
|
+
} catch (error) {
|
|
355
|
+
if (!res.headersSent) res.status(500).json({ error: error?.message || 'Failed to read media' });
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
if (startIndexer) indexService.start();
|
|
360
|
+
return { repository, indexService };
|
|
361
|
+
}
|