@oar-sh/oar 0.9.10 → 0.9.11
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 +314 -1
- package/README.md +156 -972
- package/package.json +13 -13
- package/server/README.md +95 -22
- package/server/claude-worker/claude-session-process.mjs +89 -11
- package/server/claude-worker/claude-session-worker.mjs +9 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +76 -11
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
- package/server/db-schema.mjs +59 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +4 -3
- package/server/migrations/0013-previews.mjs +130 -0
- package/server/migrations/0014-conversation-groups.mjs +89 -0
- package/server/migrations/0015-conversation-group-position.mjs +76 -0
- package/server/migrations/0016-share-access-log.mjs +110 -0
- package/server/public/app/about-ui.js +73 -0
- package/server/public/app/about-view.mjs +234 -0
- package/server/public/app/action-confirmations.js +31 -14
- package/server/public/app/agent-exchange-fold-view.mjs +40 -101
- package/server/public/app/api-client.js +251 -16
- package/server/public/app/ask-user-view.js +17 -15
- package/server/public/app/bootstrap.js +158 -32
- package/server/public/app/chat-actions-menu-state.mjs +20 -10
- package/server/public/app/context-menu.mjs +90 -6
- package/server/public/app/conversation-group-drag.mjs +191 -0
- package/server/public/app/conversation-group-order.mjs +265 -0
- package/server/public/app/conversation-group-state.mjs +100 -0
- package/server/public/app/conversation-list-drag.mjs +449 -0
- package/server/public/app/conversation-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +136 -93
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +7 -1
- package/server/public/app/journal-view.js +787 -22
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/relay-board-view.js +19 -6
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +40 -8
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/share-format.mjs +49 -0
- package/server/public/app/share-preview-marks.mjs +167 -0
- package/server/public/app/share-state.js +109 -0
- package/server/public/app/shared-conversations-settings.js +480 -0
- package/server/public/app/socket-handlers.js +80 -14
- package/server/public/app/store.js +96 -30
- package/server/public/app/subagent-row.mjs +3 -3
- package/server/public/app/subagent-viewer.mjs +30 -6
- package/server/public/app/thinking-follow.js +0 -4
- package/server/public/app/transcript-jump-bottom-view.mjs +194 -20
- package/server/public/app/transcript-jump-bottom.mjs +132 -21
- package/server/public/app/transcript-viewport-anchor.mjs +140 -0
- package/server/public/index.html +386 -15
- package/server/relay-tools.md +2 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +1 -0
- package/server/repositories/session-repository.mjs +12 -37
- package/server/repositories/share-repository.mjs +322 -0
- package/server/repositories/share-visibility-statements.mjs +10 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +184 -65
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/sessions-routes.mjs +771 -193
- package/server/server-runtime.mjs +316 -100
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +128 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/hidden-message-list.mjs +21 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/message-pin-list.mjs +1 -1
- package/server/services/preview-health-probe.mjs +8 -1
- package/server/services/preview-proxy-server.mjs +8 -4
- package/server/services/preview-registry-service.mjs +676 -26
- package/server/services/preview-static-handler.mjs +40 -7
- package/server/services/preview-store.mjs +274 -0
- package/server/services/rate-limit-service.mjs +12 -1
- package/server/services/relay-auth.mjs +183 -0
- package/server/services/relay-cli-launcher-service.mjs +3 -1
- package/server/services/relay-event-service.mjs +17 -0
- package/server/services/remote-relay-dispatcher.mjs +57 -5
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +227 -19
- package/server/services/session-worker-launch-service.mjs +190 -6
- package/server/services/session-worker-process-service.mjs +10 -6
- package/server/services/share-access-log-service.mjs +123 -0
- package/server/services/share-path-redaction.mjs +424 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/remote-relay-contract.mjs +3 -1
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/windows-system-tools.mjs +106 -0
- package/shared/worker-runtime/background-task-digest.mjs +30 -0
- package/shared/worker-runtime/heartbeat.mjs +24 -0
- package/shared/worker-runtime/kept-posts.mjs +83 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
// Public conversation shares and their access log (migration 0016).
|
|
6
|
+
//
|
|
7
|
+
// A conversation has at most one active share: its newest unrevoked row in
|
|
8
|
+
// `conversation_shares`. A share is a lineage of tokens: renewing it adds a
|
|
9
|
+
// row with a new token and the same `lineage_id` and revokes the old token in
|
|
10
|
+
// the same transaction, so the access log, keyed to the lineage, carries over.
|
|
11
|
+
// Stopping a share revokes it and deletes its log.
|
|
12
|
+
//
|
|
13
|
+
// Every reader tolerates a schema without the migration: a share then has its
|
|
14
|
+
// token as its lineage, nothing is logged, and the log reads as empty.
|
|
15
|
+
|
|
16
|
+
export const SHARE_STORE_VIEWER_IP_SETTING_KEY = 'shares_store_viewer_ip';
|
|
17
|
+
const SHARE_VIEWER_KEY_SECRET_SETTING_KEY = 'shares_viewer_key_secret';
|
|
18
|
+
|
|
19
|
+
export const SHARE_OPENS_PAGE_DEFAULT = 200;
|
|
20
|
+
export const SHARE_OPENS_PAGE_MAX = 1000;
|
|
21
|
+
|
|
22
|
+
function tableExists(db, name) {
|
|
23
|
+
return !!db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`).get(name);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isRevoked(row) {
|
|
27
|
+
return !!String(row?.revoked_at || '').trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function mapShareRow(row) {
|
|
31
|
+
if (!row?.token) return null;
|
|
32
|
+
return {
|
|
33
|
+
token: String(row.token),
|
|
34
|
+
conversationId: String(row.conversation_id || ''),
|
|
35
|
+
lineageId: String(row.lineage_id || '').trim() || String(row.token),
|
|
36
|
+
createdAt: row.created_at || null,
|
|
37
|
+
revokedAt: row.revoked_at || null,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function mapOpenRow(row) {
|
|
42
|
+
return {
|
|
43
|
+
openedAt: row.opened_at,
|
|
44
|
+
ip: row.ip || null,
|
|
45
|
+
country: row.country || null,
|
|
46
|
+
userAgent: row.user_agent || null,
|
|
47
|
+
// Enough to tell viewers apart in a list; the key itself stays here.
|
|
48
|
+
viewer: String(row.viewer_key || '').slice(0, 8) || null,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createShareRepository(db) {
|
|
53
|
+
const shareColumns = new Set(db.prepare(`PRAGMA table_info(conversation_shares)`).all().map((column) => column.name));
|
|
54
|
+
const hasLineage = shareColumns.has('lineage_id');
|
|
55
|
+
const hasLog = tableExists(db, 'conversation_share_opens');
|
|
56
|
+
const lineageSelect = hasLineage ? 'lineage_id' : 'NULL AS lineage_id';
|
|
57
|
+
const shareSelect = `token, conversation_id, created_at, revoked_at, ${lineageSelect}`;
|
|
58
|
+
|
|
59
|
+
const stmts = {
|
|
60
|
+
activeByConversation: db.prepare(`
|
|
61
|
+
SELECT ${shareSelect}
|
|
62
|
+
FROM conversation_shares
|
|
63
|
+
WHERE conversation_id = ?
|
|
64
|
+
AND (revoked_at IS NULL OR revoked_at = '')
|
|
65
|
+
ORDER BY created_at DESC
|
|
66
|
+
LIMIT 1
|
|
67
|
+
`),
|
|
68
|
+
byToken: db.prepare(`SELECT ${shareSelect} FROM conversation_shares WHERE token = ? LIMIT 1`),
|
|
69
|
+
insert: hasLineage
|
|
70
|
+
? db.prepare(`
|
|
71
|
+
INSERT INTO conversation_shares (token, conversation_id, created_at, last_accessed_at, revoked_at, lineage_id)
|
|
72
|
+
VALUES (?, ?, ?, NULL, NULL, ?)
|
|
73
|
+
`)
|
|
74
|
+
: db.prepare(`
|
|
75
|
+
INSERT INTO conversation_shares (token, conversation_id, created_at, last_accessed_at, revoked_at)
|
|
76
|
+
VALUES (?, ?, ?, NULL, NULL)
|
|
77
|
+
`),
|
|
78
|
+
revokeOthers: db.prepare(`
|
|
79
|
+
UPDATE conversation_shares
|
|
80
|
+
SET revoked_at = ?
|
|
81
|
+
WHERE conversation_id = ?
|
|
82
|
+
AND token <> ?
|
|
83
|
+
AND (revoked_at IS NULL OR revoked_at = '')
|
|
84
|
+
`),
|
|
85
|
+
revokeAll: db.prepare(`
|
|
86
|
+
UPDATE conversation_shares
|
|
87
|
+
SET revoked_at = ?
|
|
88
|
+
WHERE conversation_id = ?
|
|
89
|
+
AND (revoked_at IS NULL OR revoked_at = '')
|
|
90
|
+
`),
|
|
91
|
+
listActive: db.prepare(`
|
|
92
|
+
SELECT s.token, s.conversation_id, s.created_at, s.revoked_at, ${hasLineage ? 's.lineage_id' : 'NULL AS lineage_id'},
|
|
93
|
+
c.title AS conversation_title, c.archived AS conversation_archived
|
|
94
|
+
FROM conversation_shares s
|
|
95
|
+
JOIN conversations c ON c.id = s.conversation_id
|
|
96
|
+
WHERE (s.revoked_at IS NULL OR s.revoked_at = '')
|
|
97
|
+
AND c.status != 'deleted'
|
|
98
|
+
ORDER BY s.created_at DESC
|
|
99
|
+
`),
|
|
100
|
+
conversationStatus: db.prepare(`SELECT id, status FROM conversations WHERE id = ?`),
|
|
101
|
+
getSetting: db.prepare(`SELECT value FROM app_settings WHERE key = ? LIMIT 1`),
|
|
102
|
+
upsertSetting: db.prepare(`
|
|
103
|
+
INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
|
104
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
|
105
|
+
`),
|
|
106
|
+
};
|
|
107
|
+
// Addresses already stored: the access log's `ip` and the `viewerIp` of the
|
|
108
|
+
// status log's shared reads. A row whose payload is not JSON is left alone.
|
|
109
|
+
const hasEvents = tableExists(db, 'status_events');
|
|
110
|
+
const EVENT_IP = `CASE WHEN json_valid(payload_json) THEN json_extract(payload_json, '$.viewerIp') END`;
|
|
111
|
+
const ipSources = [
|
|
112
|
+
...(hasLog ? [`SELECT ip AS address FROM conversation_share_opens WHERE ip IS NOT NULL AND ip <> ''`] : []),
|
|
113
|
+
...(hasEvents ? [`SELECT ${EVENT_IP} AS address FROM status_events WHERE type = 'shared-access-opened' AND COALESCE(${EVENT_IP}, '') <> ''`] : []),
|
|
114
|
+
];
|
|
115
|
+
const ipStmts = {
|
|
116
|
+
count: ipSources.length ? db.prepare(`SELECT COUNT(*) AS n FROM (${ipSources.join(' UNION ')})`) : null,
|
|
117
|
+
clearOpens: hasLog ? db.prepare(`UPDATE conversation_share_opens SET ip = NULL WHERE ip IS NOT NULL`) : null,
|
|
118
|
+
clearEvents: hasEvents ? db.prepare(`
|
|
119
|
+
UPDATE status_events
|
|
120
|
+
SET payload_json = json_remove(payload_json, '$.viewerIp')
|
|
121
|
+
WHERE type = 'shared-access-opened' AND ${EVENT_IP} IS NOT NULL
|
|
122
|
+
`) : null,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const logStmts = hasLog ? {
|
|
126
|
+
insert: db.prepare(`
|
|
127
|
+
INSERT INTO conversation_share_opens (lineage_id, conversation_id, opened_at, ip, user_agent, country, viewer_key)
|
|
128
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
129
|
+
`),
|
|
130
|
+
stats: db.prepare(`
|
|
131
|
+
SELECT COUNT(*) AS opens, COUNT(DISTINCT viewer_key) AS viewers, MAX(opened_at) AS last_opened_at
|
|
132
|
+
FROM conversation_share_opens
|
|
133
|
+
WHERE lineage_id = ?
|
|
134
|
+
`),
|
|
135
|
+
page: db.prepare(`
|
|
136
|
+
SELECT opened_at, ip, user_agent, country, viewer_key
|
|
137
|
+
FROM conversation_share_opens
|
|
138
|
+
WHERE lineage_id = ?
|
|
139
|
+
ORDER BY opened_at DESC, id DESC
|
|
140
|
+
LIMIT ?
|
|
141
|
+
`),
|
|
142
|
+
lastOpenOf: db.prepare(`
|
|
143
|
+
SELECT MAX(opened_at) AS opened_at
|
|
144
|
+
FROM conversation_share_opens
|
|
145
|
+
WHERE lineage_id = ? AND viewer_key = ?
|
|
146
|
+
`),
|
|
147
|
+
deleteForConversation: db.prepare(`DELETE FROM conversation_share_opens WHERE conversation_id = ?`),
|
|
148
|
+
} : null;
|
|
149
|
+
|
|
150
|
+
function findActiveShare(conversationId) {
|
|
151
|
+
return mapShareRow(stmts.activeByConversation.get(String(conversationId || '').trim()));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function findShareByToken(token) {
|
|
155
|
+
return mapShareRow(stmts.byToken.get(String(token || '').trim()));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The share a public request may read: the token's row, neither revoked nor
|
|
160
|
+
* renewed, of a conversation that still exists. Null otherwise, and every
|
|
161
|
+
* share route answers that with a 404.
|
|
162
|
+
*/
|
|
163
|
+
function findLiveShare(token) {
|
|
164
|
+
const row = stmts.byToken.get(String(token || '').trim());
|
|
165
|
+
if (!row || isRevoked(row)) return null;
|
|
166
|
+
const share = mapShareRow(row);
|
|
167
|
+
const conversation = stmts.conversationStatus.get(share.conversationId);
|
|
168
|
+
if (!conversation || String(conversation.status || '').trim().toLowerCase() === 'deleted') return null;
|
|
169
|
+
return share;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function insertShare(conversationId, token, now, lineageId) {
|
|
173
|
+
if (hasLineage) stmts.insert.run(token, conversationId, now, lineageId || token);
|
|
174
|
+
else stmts.insert.run(token, conversationId, now);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The active share, or a new one; `created` tells which. */
|
|
178
|
+
const getOrCreateShare = db.transaction((conversationId, makeToken, now) => {
|
|
179
|
+
const existing = findActiveShare(conversationId);
|
|
180
|
+
if (existing) return { share: existing, created: false };
|
|
181
|
+
const token = makeToken();
|
|
182
|
+
insertShare(conversationId, token, now, token);
|
|
183
|
+
return { share: findShareByToken(token), created: true };
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* A new token for the conversation's active share, same lineage; every other
|
|
188
|
+
* token of the conversation is revoked with it. Null without an active share.
|
|
189
|
+
*/
|
|
190
|
+
const renewShare = db.transaction((conversationId, makeToken, now) => {
|
|
191
|
+
const existing = findActiveShare(conversationId);
|
|
192
|
+
if (!existing) return null;
|
|
193
|
+
const token = makeToken();
|
|
194
|
+
insertShare(conversationId, token, now, existing.lineageId);
|
|
195
|
+
stmts.revokeOthers.run(now, conversationId, token);
|
|
196
|
+
return { share: findShareByToken(token), previousToken: existing.token };
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
/** Revokes every token of the conversation and deletes its access log. */
|
|
200
|
+
const stopSharing = db.transaction((conversationId, now) => {
|
|
201
|
+
const revoked = stmts.revokeAll.run(now, conversationId).changes;
|
|
202
|
+
const opensDeleted = logStmts ? logStmts.deleteForConversation.run(conversationId).changes : 0;
|
|
203
|
+
return { revokedCount: Number(revoked || 0), opensDeleted: Number(opensDeleted || 0) };
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
function forgetConversation(conversationId) {
|
|
207
|
+
return logStmts ? Number(logStmts.deleteForConversation.run(conversationId).changes || 0) : 0;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function shareStats(lineageId) {
|
|
211
|
+
const row = logStmts ? logStmts.stats.get(lineageId) : null;
|
|
212
|
+
return {
|
|
213
|
+
openCount: Number(row?.opens || 0),
|
|
214
|
+
viewerCount: Number(row?.viewers || 0),
|
|
215
|
+
lastOpenedAt: row?.last_opened_at || null,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** One entry per conversation with an active share, newest share first. */
|
|
220
|
+
function listActiveShares() {
|
|
221
|
+
const seen = new Set();
|
|
222
|
+
const shares = [];
|
|
223
|
+
for (const row of stmts.listActive.all()) {
|
|
224
|
+
const conversationId = String(row.conversation_id || '');
|
|
225
|
+
if (seen.has(conversationId)) continue;
|
|
226
|
+
seen.add(conversationId);
|
|
227
|
+
const share = mapShareRow(row);
|
|
228
|
+
// The lineage started with its first token; that row holds the date.
|
|
229
|
+
const first = share.lineageId === share.token ? row : stmts.byToken.get(share.lineageId);
|
|
230
|
+
shares.push({
|
|
231
|
+
...share,
|
|
232
|
+
title: String(row.conversation_title || '').trim(),
|
|
233
|
+
archived: Number(row.conversation_archived || 0) === 1,
|
|
234
|
+
sharedSince: first?.created_at || share.createdAt,
|
|
235
|
+
...shareStats(share.lineageId),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return shares;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function recordOpen({ lineageId, conversationId, openedAt, ip = null, userAgent = null, country = null, viewerKey }) {
|
|
242
|
+
if (!logStmts) return false;
|
|
243
|
+
logStmts.insert.run(lineageId, conversationId, openedAt, ip || null, userAgent || null, country || null, viewerKey);
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function lastOpenAt(lineageId, viewerKey) {
|
|
248
|
+
return logStmts ? logStmts.lastOpenOf.get(lineageId, viewerKey)?.opened_at || null : null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function listOpens(lineageId, { limit = SHARE_OPENS_PAGE_DEFAULT } = {}) {
|
|
252
|
+
const pageSize = Math.max(1, Math.min(SHARE_OPENS_PAGE_MAX, Math.trunc(Number(limit) || SHARE_OPENS_PAGE_DEFAULT)));
|
|
253
|
+
if (!logStmts) return { opens: [], total: 0, limit: pageSize };
|
|
254
|
+
return {
|
|
255
|
+
opens: logStmts.page.all(lineageId, pageSize).map(mapOpenRow),
|
|
256
|
+
total: shareStats(lineageId).openCount,
|
|
257
|
+
limit: pageSize,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Default on: a relay records the address of a public reader unless told not to.
|
|
262
|
+
function readStoreViewerIp() {
|
|
263
|
+
return String(stmts.getSetting.get(SHARE_STORE_VIEWER_IP_SETTING_KEY)?.value || '').trim() !== 'false';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function writeStoreViewerIp(enabled) {
|
|
267
|
+
stmts.upsertSetting.run(SHARE_STORE_VIEWER_IP_SETTING_KEY, enabled ? 'true' : 'false', new Date().toISOString());
|
|
268
|
+
return readStoreViewerIp();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** How many distinct viewer addresses the relay still holds. */
|
|
272
|
+
function countStoredViewerIps() {
|
|
273
|
+
return Number(ipStmts.count?.get()?.n || 0);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Drops every stored viewer address: the access log keeps its rows and
|
|
278
|
+
* viewer keys (so viewer counts stay), the status log its events.
|
|
279
|
+
*/
|
|
280
|
+
const forgetStoredViewerIps = db.transaction(() => ({
|
|
281
|
+
opens: Number(ipStmts.clearOpens?.run().changes || 0),
|
|
282
|
+
events: Number(ipStmts.clearEvents?.run().changes || 0),
|
|
283
|
+
}));
|
|
284
|
+
|
|
285
|
+
// The key of the viewer hash, made once per relay. Kept next to the log, so
|
|
286
|
+
// it keeps addresses out of the log but does not hide them from someone
|
|
287
|
+
// holding the whole database.
|
|
288
|
+
let viewerKeySecret = '';
|
|
289
|
+
function readViewerKeySecret() {
|
|
290
|
+
if (viewerKeySecret) return viewerKeySecret;
|
|
291
|
+
const stored = String(stmts.getSetting.get(SHARE_VIEWER_KEY_SECRET_SETTING_KEY)?.value || '').trim();
|
|
292
|
+
if (stored) {
|
|
293
|
+
viewerKeySecret = stored;
|
|
294
|
+
return stored;
|
|
295
|
+
}
|
|
296
|
+
const created = randomBytes(32).toString('hex');
|
|
297
|
+
stmts.upsertSetting.run(SHARE_VIEWER_KEY_SECRET_SETTING_KEY, created, new Date().toISOString());
|
|
298
|
+
viewerKeySecret = created;
|
|
299
|
+
return created;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
logAvailable: hasLog,
|
|
304
|
+
findActiveShare,
|
|
305
|
+
findShareByToken,
|
|
306
|
+
findLiveShare,
|
|
307
|
+
getOrCreateShare,
|
|
308
|
+
renewShare,
|
|
309
|
+
stopSharing,
|
|
310
|
+
forgetConversation,
|
|
311
|
+
shareStats,
|
|
312
|
+
listActiveShares,
|
|
313
|
+
recordOpen,
|
|
314
|
+
lastOpenAt,
|
|
315
|
+
listOpens,
|
|
316
|
+
readStoreViewerIp,
|
|
317
|
+
writeStoreViewerIp,
|
|
318
|
+
countStoredViewerIps,
|
|
319
|
+
forgetStoredViewerIps,
|
|
320
|
+
readViewerKeySecret,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
@@ -20,5 +20,15 @@ export function createShareVisibilityStatements(db) {
|
|
|
20
20
|
WHERE id = ? AND conversation_id = ?
|
|
21
21
|
`)
|
|
22
22
|
: null,
|
|
23
|
+
// Conversation order, like the pin list. Null on a database without the
|
|
24
|
+
// columns: every caller treats that as "nothing hidden".
|
|
25
|
+
listHiddenMessages: messagesSupportShareVisibility
|
|
26
|
+
? db.prepare(`
|
|
27
|
+
SELECT id, role, text, mode, attachments, timestamp, share_hidden_at
|
|
28
|
+
FROM messages
|
|
29
|
+
WHERE conversation_id = ? AND hidden_from_shares = 1
|
|
30
|
+
ORDER BY timestamp ASC
|
|
31
|
+
`)
|
|
32
|
+
: null,
|
|
23
33
|
};
|
|
24
34
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// GET /api/about: the About OAR modal's data (about-service.mjs). The owner's
|
|
4
|
+
// only; a shared viewer never sees it.
|
|
5
|
+
|
|
6
|
+
export function registerAboutRoutes(app, { auth, aboutService = null } = {}) {
|
|
7
|
+
app.get('/api/about', auth, async (_req, res) => {
|
|
8
|
+
if (!aboutService) return res.status(503).json({ error: 'About is unavailable on this relay' });
|
|
9
|
+
try {
|
|
10
|
+
return res.json(await aboutService.describe());
|
|
11
|
+
} catch (error) {
|
|
12
|
+
return res.status(500).json({ error: `Reading the relay's facts failed: ${String(error?.message || error)}` });
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -14,6 +14,8 @@ export function registerCacheRoutes(app, deps) {
|
|
|
14
14
|
bootstrapRuntimeSessionBindings,
|
|
15
15
|
collectOrphanedUploadsFromConversation,
|
|
16
16
|
deleteOrphanedUploads,
|
|
17
|
+
closeConversationPreviews,
|
|
18
|
+
conversationGroups = null,
|
|
17
19
|
} = deps;
|
|
18
20
|
|
|
19
21
|
const cacheRebuildService = createCacheRebuildService({
|
|
@@ -26,6 +28,8 @@ export function registerCacheRoutes(app, deps) {
|
|
|
26
28
|
bootstrapRuntimeSessionBindings,
|
|
27
29
|
collectOrphanedUploadsFromConversation,
|
|
28
30
|
deleteOrphanedUploads,
|
|
31
|
+
closeConversationPreviews,
|
|
32
|
+
conversationGroups,
|
|
29
33
|
});
|
|
30
34
|
|
|
31
35
|
app.post('/api/cache/rebuild', auth, (req, res) => {
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Session groups, made and changed by hand in the sidebar: a new group from
|
|
4
|
+
// one or more conversations, a name, a conversation moved in or out (or
|
|
5
|
+
// dragged to a place inside a group), and a group dissolved. Archiving a group lives with the archive route
|
|
6
|
+
// (sessions-routes.mjs) and stopping one with the turn controls
|
|
7
|
+
// (messages-routes.mjs), next to the code they reuse.
|
|
8
|
+
//
|
|
9
|
+
// The owner's action only: every route is behind `auth`, and a request that
|
|
10
|
+
// arrived from a paired relay is refused, as the remote_relay tool's own
|
|
11
|
+
// route refuses calls from other relays. The tool reads groups, never
|
|
12
|
+
// changes them.
|
|
13
|
+
|
|
14
|
+
import { readRemoteRelayRequest } from '../services/remote-relay-inbound.mjs';
|
|
15
|
+
import { GROUP_NAME_MAX } from '../repositories/conversation-group-repository.mjs';
|
|
16
|
+
import { resolveConversationTitle } from './sessions-routes.mjs';
|
|
17
|
+
|
|
18
|
+
const resolveConversationTitleText = (title) => resolveConversationTitle({ title, titleSource: 'auto' });
|
|
19
|
+
|
|
20
|
+
export function registerConversationGroupRoutes(app, deps) {
|
|
21
|
+
const {
|
|
22
|
+
auth,
|
|
23
|
+
io,
|
|
24
|
+
conversationGroups = null,
|
|
25
|
+
remoteRelayInbound = null,
|
|
26
|
+
} = deps;
|
|
27
|
+
|
|
28
|
+
function refuseRemote(req, res) {
|
|
29
|
+
if (!readRemoteRelayRequest(req, remoteRelayInbound).remote) return false;
|
|
30
|
+
res.status(403).json({ error: 'Session groups are managed by this relay\'s owner only.' });
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function unavailable(res) {
|
|
35
|
+
return res.status(503).json({ ok: false, error: 'Session groups are not available on this relay (its storage for them is missing).' });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// What the page can apply in place: the group's summary (null once
|
|
39
|
+
// dissolved), the named conversations' membership, the group's order (head
|
|
40
|
+
// first) and the positions of its members and of the named conversations.
|
|
41
|
+
function emitChanged(groupId, conversationIds = []) {
|
|
42
|
+
const ids = [...new Set(conversationIds.map((id) => String(id || '').trim()).filter(Boolean))];
|
|
43
|
+
const group = groupId ? conversationGroups.describeGroup(groupId, { headTitle: resolveConversationTitleText }) : null;
|
|
44
|
+
const order = group ? conversationGroups.orderOf(groupId) : [];
|
|
45
|
+
io.emit('conversation_group_changed', {
|
|
46
|
+
groupId: groupId || null,
|
|
47
|
+
conversationIds: ids,
|
|
48
|
+
group,
|
|
49
|
+
memberships: conversationGroups.membershipsOf(ids),
|
|
50
|
+
order,
|
|
51
|
+
positions: conversationGroups.positionsOf([...order, ...ids]),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function groupPayload(group) {
|
|
56
|
+
if (!group) return null;
|
|
57
|
+
return {
|
|
58
|
+
id: group.id,
|
|
59
|
+
name: group.name || null,
|
|
60
|
+
parentConversationId: group.parentConversationId || null,
|
|
61
|
+
members: conversationGroups.membersOf(group.id).map((member) => member.id),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// POST /api/conversation-groups { name?, conversationIds } — a group by hand.
|
|
66
|
+
app.post('/api/conversation-groups', auth, (req, res) => {
|
|
67
|
+
if (refuseRemote(req, res)) return;
|
|
68
|
+
if (!conversationGroups?.enabled) return unavailable(res);
|
|
69
|
+
const ids = (Array.isArray(req.body?.conversationIds) ? req.body.conversationIds : [])
|
|
70
|
+
.map((id) => String(id || '').trim())
|
|
71
|
+
.filter(Boolean);
|
|
72
|
+
if (!ids.length) return res.status(400).json({ ok: false, error: 'conversationIds must name at least one conversation' });
|
|
73
|
+
const name = req.body?.name == null ? null : String(req.body.name);
|
|
74
|
+
if (name && name.trim().length > GROUP_NAME_MAX) {
|
|
75
|
+
return res.status(400).json({ ok: false, error: `A group name has at most ${GROUP_NAME_MAX} characters` });
|
|
76
|
+
}
|
|
77
|
+
// The rows leave the groups they were in (a drop from a view that had
|
|
78
|
+
// not caught up yet): those groups are announced too.
|
|
79
|
+
const previousGroupIds = new Map();
|
|
80
|
+
for (const id of ids) {
|
|
81
|
+
const previous = conversationGroups.groupOfConversation(id)?.id;
|
|
82
|
+
if (!previous) continue;
|
|
83
|
+
if (!previousGroupIds.has(previous)) previousGroupIds.set(previous, []);
|
|
84
|
+
previousGroupIds.get(previous).push(id);
|
|
85
|
+
}
|
|
86
|
+
const group = conversationGroups.createGroup({ name, conversationIds: ids });
|
|
87
|
+
const payload = groupPayload(group);
|
|
88
|
+
if (!payload.members.length) {
|
|
89
|
+
conversationGroups.deleteIfEmpty(group.id);
|
|
90
|
+
return res.status(404).json({ ok: false, error: 'None of the conversations exists' });
|
|
91
|
+
}
|
|
92
|
+
emitChanged(group.id, ids);
|
|
93
|
+
for (const [previous, movedIds] of previousGroupIds) emitChanged(previous, movedIds);
|
|
94
|
+
return res.json({ ok: true, group: payload });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// PATCH /api/conversation-groups/:id { name } — rename; empty returns to the head's title.
|
|
98
|
+
app.patch('/api/conversation-groups/:id', auth, (req, res) => {
|
|
99
|
+
if (refuseRemote(req, res)) return;
|
|
100
|
+
if (!conversationGroups?.enabled) return unavailable(res);
|
|
101
|
+
const groupId = String(req.params.id || '').trim();
|
|
102
|
+
if (!conversationGroups.getGroup(groupId)) return res.status(404).json({ ok: false, error: 'Group not found' });
|
|
103
|
+
if (!('name' in (req.body || {}))) return res.status(400).json({ ok: false, error: 'name is required' });
|
|
104
|
+
const name = req.body.name == null ? '' : String(req.body.name);
|
|
105
|
+
if (name.trim().length > GROUP_NAME_MAX) {
|
|
106
|
+
return res.status(400).json({ ok: false, error: `A group name has at most ${GROUP_NAME_MAX} characters` });
|
|
107
|
+
}
|
|
108
|
+
const group = conversationGroups.renameGroup(groupId, name);
|
|
109
|
+
emitChanged(groupId, []);
|
|
110
|
+
return res.json({ ok: true, group: groupPayload(group) });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// PUT /api/conversation/:id/group { groupId, beforeConversationId? } — move
|
|
114
|
+
// a conversation into a group. `groupId: null` takes it out (as DELETE).
|
|
115
|
+
// With `beforeConversationId` in the body (null: to the end) it is a drag:
|
|
116
|
+
// the row lands before that one and the group gets a hand order; without
|
|
117
|
+
// it the row joins at the end of a hand-ordered group, as the menu does.
|
|
118
|
+
app.put('/api/conversation/:id/group', auth, (req, res) => {
|
|
119
|
+
if (refuseRemote(req, res)) return;
|
|
120
|
+
if (!conversationGroups?.enabled) return unavailable(res);
|
|
121
|
+
const body = req.body || {};
|
|
122
|
+
const conversationId = String(req.params.id || '').trim();
|
|
123
|
+
const idOrNull = (value) => value === null || typeof value === 'string';
|
|
124
|
+
if (!idOrNull(body.groupId ?? null)) return res.status(400).json({ ok: false, error: 'groupId must be a string or null' });
|
|
125
|
+
if ('beforeConversationId' in body && !idOrNull(body.beforeConversationId)) {
|
|
126
|
+
return res.status(400).json({ ok: false, error: 'beforeConversationId must be a string or null' });
|
|
127
|
+
}
|
|
128
|
+
const leaving = 'groupId' in body && body.groupId === null;
|
|
129
|
+
const groupId = leaving ? '' : String(body.groupId || '').trim();
|
|
130
|
+
if (!groupId && !leaving) return res.status(400).json({ ok: false, error: 'groupId is required' });
|
|
131
|
+
const placing = 'beforeConversationId' in body;
|
|
132
|
+
const result = placing
|
|
133
|
+
? conversationGroups.moveConversation(conversationId, {
|
|
134
|
+
groupId: groupId || null,
|
|
135
|
+
beforeConversationId: body.beforeConversationId == null ? null : String(body.beforeConversationId).trim() || null,
|
|
136
|
+
})
|
|
137
|
+
: conversationGroups.setMembership(conversationId, groupId || null);
|
|
138
|
+
if (!result.ok) return res.status(404).json({ ok: false, error: result.error });
|
|
139
|
+
if (result.changed && groupId) emitChanged(groupId, [conversationId]);
|
|
140
|
+
if (result.changed && result.previousGroupId && result.previousGroupId !== groupId) emitChanged(result.previousGroupId, [conversationId]);
|
|
141
|
+
return res.json({
|
|
142
|
+
ok: true,
|
|
143
|
+
groupId: groupId || null,
|
|
144
|
+
previousGroupId: result.previousGroupId || null,
|
|
145
|
+
changed: result.changed === true,
|
|
146
|
+
...(placing ? { order: result.order || [] } : {}),
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// DELETE /api/conversation/:id/group — move a conversation out of its group.
|
|
151
|
+
app.delete('/api/conversation/:id/group', auth, (req, res) => {
|
|
152
|
+
if (refuseRemote(req, res)) return;
|
|
153
|
+
if (!conversationGroups?.enabled) return unavailable(res);
|
|
154
|
+
const conversationId = String(req.params.id || '').trim();
|
|
155
|
+
const result = conversationGroups.setMembership(conversationId, null);
|
|
156
|
+
if (!result.ok) return res.status(404).json({ ok: false, error: result.error });
|
|
157
|
+
if (result.changed) emitChanged(result.previousGroupId, [conversationId]);
|
|
158
|
+
return res.json({ ok: true, previousGroupId: result.previousGroupId || null, changed: result.changed === true });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// DELETE /api/conversation-groups/:id — dissolve: members stay, ungrouped.
|
|
162
|
+
app.delete('/api/conversation-groups/:id', auth, (req, res) => {
|
|
163
|
+
if (refuseRemote(req, res)) return;
|
|
164
|
+
if (!conversationGroups?.enabled) return unavailable(res);
|
|
165
|
+
const groupId = String(req.params.id || '').trim();
|
|
166
|
+
const members = conversationGroups.membersOf(groupId).map((member) => member.id);
|
|
167
|
+
if (!conversationGroups.dissolveGroup(groupId)) return res.status(404).json({ ok: false, error: 'Group not found' });
|
|
168
|
+
emitChanged(groupId, members);
|
|
169
|
+
return res.json({ ok: true, members });
|
|
170
|
+
});
|
|
171
|
+
}
|