@oar-sh/oar 0.9.24 → 0.9.25
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 +142 -0
- package/README.md +12 -11
- package/package.json +3 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +15 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +11 -9
- package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
- package/server/db-schema.mjs +19 -0
- 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/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/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/relay-error-ctas.mjs +5 -0
- package/server/public/app/session-history-ui.js +2 -0
- package/server/public/app/settings-modal.js +4 -0
- package/server/public/app/socket-handlers.js +26 -0
- package/server/public/app/tab-strip-fade.mjs +1 -1
- package/server/public/app/viewer-gallery.mjs +23 -1
- package/server/public/index.html +383 -7
- 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/sessions-routes.mjs +361 -14
- package/server/server-runtime.mjs +19 -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/remote-relay-caller-context.mjs +6 -0
- package/server/services/remote-relay-dispatcher.mjs +386 -22
- package/server/services/remote-relay-registry-service.mjs +3 -1
- package/server/services/token-usage-service.mjs +15 -0
- package/shared/image-header.mjs +90 -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
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The media gallery index (migration 0039): the queue of messages to
|
|
4
|
+
// (re)index and the `media_items` rows made from them. Every reader tolerates
|
|
5
|
+
// a schema without the migration: it then sees an empty index and writes
|
|
6
|
+
// nothing.
|
|
7
|
+
|
|
8
|
+
export const MEDIA_PENDING_COUNT_CAP = 10_000;
|
|
9
|
+
|
|
10
|
+
function tableExists(db, name) {
|
|
11
|
+
return !!db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`).get(name);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function columnsOf(db, table) {
|
|
15
|
+
return db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Who a message comes from, for the Source filter: the owner, an agent, or the relay itself. */
|
|
19
|
+
export function mediaSenderFor({ role, kind, originJson }) {
|
|
20
|
+
if (role === 'assistant') return 'agent';
|
|
21
|
+
if (role === 'user' && !kind && !originJson) return 'me';
|
|
22
|
+
return 'relay';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createMediaRepository(db) {
|
|
26
|
+
const available = tableExists(db, 'media_items')
|
|
27
|
+
&& tableExists(db, 'media_index_queue')
|
|
28
|
+
&& tableExists(db, 'messages');
|
|
29
|
+
if (!available) {
|
|
30
|
+
return {
|
|
31
|
+
available: false,
|
|
32
|
+
nextQueueBatch: () => [],
|
|
33
|
+
loadQueuedMessage: () => null,
|
|
34
|
+
replaceMessageItems: () => 0,
|
|
35
|
+
pendingCount: () => 0,
|
|
36
|
+
hasPending: () => false,
|
|
37
|
+
removeOrphanQueueRows: () => 0,
|
|
38
|
+
inTransaction: (fn) => fn(),
|
|
39
|
+
listMedia: () => [],
|
|
40
|
+
countMedia: () => ({ media: 0, file: 0 }),
|
|
41
|
+
mediaFacets: () => ({ sessions: [], providers: [] }),
|
|
42
|
+
getMediaItem: () => null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const messageColumns = columnsOf(db, 'messages');
|
|
46
|
+
// octet_length reads a value's size without reading the value (SQLite 3.43+).
|
|
47
|
+
let hasOctetLength = true;
|
|
48
|
+
try {
|
|
49
|
+
db.prepare(`SELECT octet_length('x')`).get();
|
|
50
|
+
} catch {
|
|
51
|
+
hasOctetLength = false;
|
|
52
|
+
}
|
|
53
|
+
const sizeOf = (column) => (hasOctetLength ? `octet_length(${column})` : `length(${column})`);
|
|
54
|
+
const conversationColumns = columnsOf(db, 'conversations');
|
|
55
|
+
const optional = (columns, column, alias) => (columns.includes(column) ? `${alias}.${column}` : 'NULL');
|
|
56
|
+
const workspaceRoot = `COALESCE(${optional(conversationColumns, 'runtime_workspace_root_path', 'c')}, ${optional(conversationColumns, 'configured_workspace_root_path', 'c')})`;
|
|
57
|
+
|
|
58
|
+
const statements = {
|
|
59
|
+
// Queue order is the rowid (insertion order); no sort over the queue.
|
|
60
|
+
// Ids and body sizes only: a batch never loads message bodies, which can be
|
|
61
|
+
// megabytes (a pasted data image); the indexer loads them one by one.
|
|
62
|
+
nextBatch: db.prepare(`
|
|
63
|
+
SELECT q.message_id AS messageId, q.seq AS seq,
|
|
64
|
+
${sizeOf('m.text')} + COALESCE(${sizeOf('m.attachments')}, 0) AS bytes
|
|
65
|
+
FROM media_index_queue q
|
|
66
|
+
JOIN messages m ON m.id = q.message_id
|
|
67
|
+
ORDER BY q.rowid
|
|
68
|
+
LIMIT ?
|
|
69
|
+
`),
|
|
70
|
+
loadMessage: db.prepare(`
|
|
71
|
+
SELECT m.id AS messageId,
|
|
72
|
+
m.conversation_id AS conversationId, m.role AS role, m.text AS text,
|
|
73
|
+
m.attachments AS attachments, m.timestamp AS timestamp,
|
|
74
|
+
${optional(messageColumns, 'kind', 'm')} AS kind,
|
|
75
|
+
${optional(messageColumns, 'origin_json', 'm')} AS originJson,
|
|
76
|
+
${workspaceRoot} AS workspaceRoot
|
|
77
|
+
FROM messages m
|
|
78
|
+
LEFT JOIN conversations c ON c.id = m.conversation_id
|
|
79
|
+
WHERE m.id = ?
|
|
80
|
+
`),
|
|
81
|
+
deleteItems: db.prepare(`DELETE FROM media_items WHERE message_id = ?`),
|
|
82
|
+
insertItem: db.prepare(`
|
|
83
|
+
INSERT INTO media_items (message_id, conversation_id, ordinal, role, sender, origin, locator_kind, locator,
|
|
84
|
+
identity_key, category, media_kind, mime, name, alt, size_bytes, message_ts)
|
|
85
|
+
VALUES (@messageId, @conversationId, @ordinal, @role, @sender, @origin, @locatorKind, @locator,
|
|
86
|
+
@identityKey, @category, @mediaKind, @mime, @name, @alt, @sizeBytes, @messageTs)
|
|
87
|
+
`),
|
|
88
|
+
// Only the queue row that was read: a re-queue meanwhile bumped `seq`
|
|
89
|
+
// and keeps the row for the next batch.
|
|
90
|
+
deleteQueueRow: db.prepare(`DELETE FROM media_index_queue WHERE message_id = ? AND seq = ?`),
|
|
91
|
+
// Capped: a full count over a large backfill queue costs tens of ms.
|
|
92
|
+
pending: db.prepare(`SELECT COUNT(*) AS n FROM (SELECT 1 FROM media_index_queue LIMIT ${MEDIA_PENDING_COUNT_CAP + 1})`),
|
|
93
|
+
hasPending: db.prepare(`SELECT EXISTS (SELECT 1 FROM media_index_queue) AS present`),
|
|
94
|
+
// Rows whose message is gone while foreign keys were off (an outside tool).
|
|
95
|
+
removeOrphans: db.prepare(`DELETE FROM media_index_queue WHERE message_id NOT IN (SELECT id FROM messages)`),
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const replace = db.transaction((row, items) => {
|
|
99
|
+
statements.deleteItems.run(row.messageId);
|
|
100
|
+
const sender = mediaSenderFor(row);
|
|
101
|
+
for (const item of items) {
|
|
102
|
+
statements.insertItem.run({
|
|
103
|
+
messageId: row.messageId,
|
|
104
|
+
conversationId: row.conversationId,
|
|
105
|
+
ordinal: item.ordinal,
|
|
106
|
+
role: String(row.role || ''),
|
|
107
|
+
sender,
|
|
108
|
+
origin: item.origin,
|
|
109
|
+
locatorKind: item.locatorKind,
|
|
110
|
+
locator: item.locator,
|
|
111
|
+
identityKey: item.identityKey,
|
|
112
|
+
category: item.category,
|
|
113
|
+
mediaKind: item.mediaKind,
|
|
114
|
+
mime: item.mime ?? null,
|
|
115
|
+
name: item.name ?? null,
|
|
116
|
+
alt: item.alt ?? null,
|
|
117
|
+
sizeBytes: Number.isFinite(item.sizeBytes) ? item.sizeBytes : null,
|
|
118
|
+
messageTs: String(row.timestamp || ''),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
statements.deleteQueueRow.run(row.messageId, row.seq);
|
|
122
|
+
return items.length;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
available: true,
|
|
127
|
+
/** Queued messages in queue order: `{ messageId, seq, bytes }`, no bodies. */
|
|
128
|
+
nextQueueBatch: (limit = 200) => statements.nextBatch.all(Math.max(1, Math.min(1000, Number(limit) || 200))),
|
|
129
|
+
/** A queued message with what the indexer needs; null when it is gone. */
|
|
130
|
+
loadQueuedMessage: (entry) => {
|
|
131
|
+
const row = statements.loadMessage.get(entry.messageId);
|
|
132
|
+
return row ? { ...row, seq: entry.seq } : null;
|
|
133
|
+
},
|
|
134
|
+
/** Replaces the rows of one queued message and removes its queue row, in one transaction. */
|
|
135
|
+
replaceMessageItems: (row, items) => replace(row, items),
|
|
136
|
+
/** Queued messages, counted up to MEDIA_PENDING_COUNT_CAP + 1 (that value means "more"). */
|
|
137
|
+
pendingCount: () => statements.pending.get().n,
|
|
138
|
+
hasPending: () => statements.hasPending.get().present === 1,
|
|
139
|
+
removeOrphanQueueRows: () => statements.removeOrphans.run().changes,
|
|
140
|
+
/** Runs `fn` in one transaction (one commit for a whole slice of messages). */
|
|
141
|
+
inTransaction: (fn) => db.transaction(fn)(),
|
|
142
|
+
...createMediaQueries(db),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// --- reading the gallery ---------------------------------------------------
|
|
147
|
+
|
|
148
|
+
export const MEDIA_SOURCES = Object.freeze(['all', 'mine', 'agent', 'generated']);
|
|
149
|
+
export const MEDIA_PAGE_MAX = 120;
|
|
150
|
+
|
|
151
|
+
function escapeLike(value) {
|
|
152
|
+
return String(value).replace(/[\\%_]/g, (ch) => `\\${ch}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Opaque page cursor: the (message time, id) of the last item of a page. */
|
|
156
|
+
export function encodeMediaCursor(item) {
|
|
157
|
+
return Buffer.from(JSON.stringify([item.timestamp, item.id]), 'utf8').toString('base64url');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function decodeMediaCursor(raw) {
|
|
161
|
+
if (!raw) return null;
|
|
162
|
+
try {
|
|
163
|
+
const [timestamp, id] = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8'));
|
|
164
|
+
if (typeof timestamp !== 'string' || !Number.isSafeInteger(id)) return null;
|
|
165
|
+
return { timestamp, id };
|
|
166
|
+
} catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function createMediaQueries(db) {
|
|
172
|
+
const runtimeColumns = tableExists(db, 'runtime_sessions') ? columnsOf(db, 'runtime_sessions') : [];
|
|
173
|
+
// The session's provider, as the sidebar shows it (github when unknown).
|
|
174
|
+
const providerExpr = runtimeColumns.includes('provider_type')
|
|
175
|
+
? `COALESCE((SELECT rs.provider_type FROM runtime_sessions rs WHERE rs.conversation_id = c.id AND rs.provider_type IS NOT NULL ORDER BY rs.last_used_at DESC LIMIT 1), 'github')`
|
|
176
|
+
: `'github'`;
|
|
177
|
+
const cache = new Map();
|
|
178
|
+
const prepared = (key, sql) => {
|
|
179
|
+
if (!cache.has(key)) cache.set(key, db.prepare(sql));
|
|
180
|
+
return cache.get(key);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const providerFor = (c) => providerExpr.replace('rs.conversation_id = c.id', `rs.conversation_id = ${c}.id`);
|
|
184
|
+
|
|
185
|
+
// The scope's conditions for a media row `mi` of a session `c` (aliases
|
|
186
|
+
// given, so the dedupe check can ask them of another row).
|
|
187
|
+
function scopeClauses({ conversationId, source, provider, archived, q }, params, mi = 'mi', c = 'c') {
|
|
188
|
+
const where = [`${c}.status != 'deleted'`];
|
|
189
|
+
const key = [];
|
|
190
|
+
if (conversationId) {
|
|
191
|
+
where.push(`${mi}.conversation_id = @conversationId`);
|
|
192
|
+
params.conversationId = String(conversationId);
|
|
193
|
+
key.push('conv');
|
|
194
|
+
}
|
|
195
|
+
if (source === 'mine') { where.push(`${mi}.sender = 'me' AND ${mi}.origin != 'generated'`); key.push('mine'); }
|
|
196
|
+
if (source === 'agent') { where.push(`${mi}.sender != 'me' AND ${mi}.origin != 'generated'`); key.push('agent'); }
|
|
197
|
+
if (source === 'generated') { where.push(`${mi}.origin = 'generated'`); key.push('generated'); }
|
|
198
|
+
if (provider) {
|
|
199
|
+
where.push(`${providerFor(c)} = @provider`);
|
|
200
|
+
params.provider = String(provider);
|
|
201
|
+
key.push('provider');
|
|
202
|
+
}
|
|
203
|
+
if (archived === 'hide') { where.push(`${c}.archived = 0`); key.push('live'); }
|
|
204
|
+
const text = String(q || '').trim().slice(0, 200);
|
|
205
|
+
if (text) {
|
|
206
|
+
where.push(`(${mi}.name LIKE @q ESCAPE '\\' OR ${mi}.alt LIKE @q ESCAPE '\\' OR ${c}.title LIKE @q ESCAPE '\\')`);
|
|
207
|
+
params.q = `%${escapeLike(text)}%`;
|
|
208
|
+
key.push('q');
|
|
209
|
+
}
|
|
210
|
+
return { where: where.join(' AND '), key: key.join(',') };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* One page of a gallery: each picture once (its newest occurrence in the
|
|
215
|
+
* scope), newest first. The rows are walked newest first along the time
|
|
216
|
+
* index and a row is kept when no newer row of the scope has its identity,
|
|
217
|
+
* so a page stops after `limit` hits instead of sorting the whole scope,
|
|
218
|
+
* and an older copy of a picture never comes back on a later page. The
|
|
219
|
+
* occurrences, the session's title and its provider are read for the
|
|
220
|
+
* page's rows alone.
|
|
221
|
+
*/
|
|
222
|
+
function listMedia({ category = 'media', cursor = null, limit = 60, ...filters } = {}) {
|
|
223
|
+
const params = { category: category === 'file' ? 'file' : 'media', limit: Math.max(1, Math.min(MEDIA_PAGE_MAX, Number(limit) || 60)) };
|
|
224
|
+
const scope = scopeClauses(filters, params);
|
|
225
|
+
// For the other rows of an identity (n: a newer copy, o: every copy) the
|
|
226
|
+
// columns are written as +col: a unary plus keeps a column out of index
|
|
227
|
+
// lookups, so identity_key = ? is the only one left and the planner takes
|
|
228
|
+
// idx_media_items_identity by itself (an index hint would fail outright on
|
|
229
|
+
// a database that lacks the index; this falls back to a slower scan).
|
|
230
|
+
const newer = scopeClauses(filters, {}, '+n', 'cn');
|
|
231
|
+
const same = scopeClauses(filters, {}, '+o', 'co');
|
|
232
|
+
let after = '';
|
|
233
|
+
if (cursor) {
|
|
234
|
+
after = 'AND (mi.message_ts < @cursorTs OR (mi.message_ts = @cursorTs AND mi.id < @cursorId))';
|
|
235
|
+
params.cursorTs = cursor.timestamp;
|
|
236
|
+
params.cursorId = cursor.id;
|
|
237
|
+
}
|
|
238
|
+
const sql = `
|
|
239
|
+
SELECT mi.id, mi.conversation_id AS conversationId, mi.message_id AS messageId, mi.role, mi.sender, mi.origin,
|
|
240
|
+
mi.locator_kind AS locatorKind, mi.locator, mi.identity_key AS identityKey, mi.category,
|
|
241
|
+
mi.media_kind AS mediaKind, mi.mime, mi.name, mi.alt, mi.size_bytes AS sizeBytes,
|
|
242
|
+
mi.message_ts AS message_ts, c.title AS conversationTitle, c.archived AS archived,
|
|
243
|
+
${providerFor('c')} AS provider,
|
|
244
|
+
(SELECT COUNT(*) FROM media_items o JOIN conversations co ON co.id = o.conversation_id
|
|
245
|
+
WHERE o.identity_key = mi.identity_key AND +o.category = mi.category AND ${same.where}) AS occurrences
|
|
246
|
+
FROM media_items mi
|
|
247
|
+
JOIN conversations c ON c.id = mi.conversation_id
|
|
248
|
+
WHERE mi.category = @category AND ${scope.where} ${after}
|
|
249
|
+
AND EXISTS (SELECT 1 FROM messages m WHERE m.id = mi.message_id)
|
|
250
|
+
AND NOT EXISTS (
|
|
251
|
+
SELECT 1 FROM media_items n JOIN conversations cn ON cn.id = n.conversation_id
|
|
252
|
+
WHERE n.identity_key = mi.identity_key AND +n.category = mi.category
|
|
253
|
+
AND (+n.message_ts, +n.id) > (mi.message_ts, mi.id)
|
|
254
|
+
AND ${newer.where})
|
|
255
|
+
ORDER BY mi.message_ts DESC, mi.id DESC
|
|
256
|
+
LIMIT @limit`;
|
|
257
|
+
const rows = prepared(`list:${scope.key}:${cursor ? 'c' : ''}`, sql).all(params);
|
|
258
|
+
return rows.map(({ message_ts: timestamp, archived, ...row }) => ({ ...row, timestamp, archived: archived === 1 }));
|
|
259
|
+
}
|
|
260
|
+
/** How many distinct pictures and files a scope has. */
|
|
261
|
+
function countMedia(filters = {}) {
|
|
262
|
+
const params = {};
|
|
263
|
+
const scope = scopeClauses(filters, params);
|
|
264
|
+
const sql = `
|
|
265
|
+
SELECT mi.category AS category, COUNT(DISTINCT mi.identity_key) AS n
|
|
266
|
+
FROM media_items mi
|
|
267
|
+
JOIN messages m ON m.id = mi.message_id
|
|
268
|
+
JOIN conversations c ON c.id = mi.conversation_id
|
|
269
|
+
WHERE ${scope.where}
|
|
270
|
+
GROUP BY mi.category`;
|
|
271
|
+
const counts = { media: 0, file: 0 };
|
|
272
|
+
for (const row of prepared(`count:${scope.key}`, sql).all(params)) counts[row.category] = row.n;
|
|
273
|
+
return counts;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Sessions and providers that have media, for the global filters. */
|
|
277
|
+
function mediaFacets() {
|
|
278
|
+
const sessions = prepared('facets:sessions', `
|
|
279
|
+
SELECT c.id AS id, c.title AS title, c.archived AS archived, ${providerExpr} AS provider,
|
|
280
|
+
COUNT(DISTINCT mi.identity_key) AS count, MAX(mi.message_ts) AS lastAt
|
|
281
|
+
FROM media_items mi
|
|
282
|
+
JOIN conversations c ON c.id = mi.conversation_id
|
|
283
|
+
WHERE c.status != 'deleted'
|
|
284
|
+
GROUP BY c.id
|
|
285
|
+
ORDER BY lastAt DESC
|
|
286
|
+
LIMIT 1000`).all().map((row) => ({ ...row, archived: row.archived === 1 }));
|
|
287
|
+
const providers = [...new Set(sessions.map((row) => row.provider).filter(Boolean))].sort();
|
|
288
|
+
return { sessions, providers };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** One indexed item with what serving it needs; null when gone or its session is deleted. */
|
|
292
|
+
function getMediaItem(id) {
|
|
293
|
+
if (!Number.isSafeInteger(id) || id <= 0) return null;
|
|
294
|
+
return prepared('item', `
|
|
295
|
+
SELECT mi.id, mi.conversation_id AS conversationId, mi.message_id AS messageId, mi.locator_kind AS locatorKind,
|
|
296
|
+
mi.locator, mi.identity_key AS identityKey, mi.category, mi.media_kind AS mediaKind, mi.mime, mi.name,
|
|
297
|
+
m.text AS text, m.attachments AS attachments
|
|
298
|
+
FROM media_items mi
|
|
299
|
+
JOIN messages m ON m.id = mi.message_id
|
|
300
|
+
JOIN conversations c ON c.id = mi.conversation_id
|
|
301
|
+
WHERE mi.id = ? AND c.status != 'deleted'`).get(id) || null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return { listMedia, countMedia, mediaFacets, getMediaItem };
|
|
305
|
+
}
|
|
@@ -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
|
+
}
|