sharktide commited on
Commit ·
82f6446
1
Parent(s): e0fe1d6
Store data in bucket
Browse files- server/index.js +479 -0
- server/mediaStore.js +15 -0
- server/sessionStore.js +439 -169
- server/wsHandler.js +13 -9
server/index.js
CHANGED
|
@@ -220,6 +220,7 @@ app.use(express.json({ limit: '10mb' }));
|
|
| 220 |
// --- API Turnstile Protection ---
|
| 221 |
app.use('/api', (req, res, next) => {
|
| 222 |
const exempt = ['/turnstile', '/health'];
|
|
|
|
| 223 |
if (exempt.includes(req.path)) return next();
|
| 224 |
const cookieHeader = req.headers.cookie || '';
|
| 225 |
if (cookieHeader.includes('turnstile=1')) return next();
|
|
@@ -259,6 +260,114 @@ async function requireRequestOwner(req, res) {
|
|
| 259 |
return resolved;
|
| 260 |
}
|
| 261 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
function requireSignedInMediaOwner(resolved, res, message = 'Sign in to upload files.') {
|
| 263 |
if (resolved?.owner?.type === 'user') return true;
|
| 264 |
res.status(403).json({ error: 'media:auth_required', message });
|
|
@@ -282,6 +391,376 @@ app.get('/api/share/:token', async (req,res) => {
|
|
| 282 |
} catch { res.status(500).json({error:'Server error'}); }
|
| 283 |
});
|
| 284 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
app.get('/api/media', async (req, res) => {
|
| 286 |
const resolved = await requireRequestOwner(req, res);
|
| 287 |
if (!resolved) return;
|
|
|
|
| 220 |
// --- API Turnstile Protection ---
|
| 221 |
app.use('/api', (req, res, next) => {
|
| 222 |
const exempt = ['/turnstile', '/health'];
|
| 223 |
+
if (req.path === '/db' || req.path.startsWith('/db/')) return next();
|
| 224 |
if (exempt.includes(req.path)) return next();
|
| 225 |
const cookieHeader = req.headers.cookie || '';
|
| 226 |
if (cookieHeader.includes('turnstile=1')) return next();
|
|
|
|
| 260 |
return resolved;
|
| 261 |
}
|
| 262 |
|
| 263 |
+
function getBearerToken(req) {
|
| 264 |
+
const authHeader = String(req.headers.authorization || '').trim();
|
| 265 |
+
if (!authHeader.toLowerCase().startsWith('bearer ')) return '';
|
| 266 |
+
return authHeader.slice(7).trim();
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
async function requireJwtUser(req, res) {
|
| 270 |
+
const accessToken = getBearerToken(req);
|
| 271 |
+
if (!accessToken) {
|
| 272 |
+
res.status(401).json({
|
| 273 |
+
error: 'auth:required',
|
| 274 |
+
message: 'Provide Authorization: Bearer <supabase_jwt>.',
|
| 275 |
+
});
|
| 276 |
+
return null;
|
| 277 |
+
}
|
| 278 |
+
const user = await verifySupabaseToken(accessToken);
|
| 279 |
+
if (!user?.id) {
|
| 280 |
+
res.status(401).json({
|
| 281 |
+
error: 'auth:invalid_token',
|
| 282 |
+
message: 'Supabase JWT is invalid or expired.',
|
| 283 |
+
});
|
| 284 |
+
return null;
|
| 285 |
+
}
|
| 286 |
+
return { user, owner: { type: 'user', id: user.id }, accessToken };
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
function queryBool(value, defaultValue = false) {
|
| 290 |
+
if (value === undefined || value === null || value === '') return defaultValue;
|
| 291 |
+
const normalized = String(value).trim().toLowerCase();
|
| 292 |
+
return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
function ensureStringArray(value) {
|
| 296 |
+
if (!Array.isArray(value)) return [];
|
| 297 |
+
return value.map((v) => String(v || '').trim()).filter(Boolean);
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
function buildDatabaseApiDocs() {
|
| 301 |
+
return {
|
| 302 |
+
name: 'InferencePort Local Database API',
|
| 303 |
+
version: '1.0',
|
| 304 |
+
auth: {
|
| 305 |
+
type: 'Bearer Supabase JWT',
|
| 306 |
+
header: 'Authorization: Bearer <supabase_access_token>',
|
| 307 |
+
authorizationRule: 'Every request is scoped to the JWT user id. Request payloads cannot override owner/user id.',
|
| 308 |
+
errors: {
|
| 309 |
+
unauthenticated: { status: 401, error: 'auth:required' },
|
| 310 |
+
invalidToken: { status: 401, error: 'auth:invalid_token' },
|
| 311 |
+
},
|
| 312 |
+
},
|
| 313 |
+
storage: {
|
| 314 |
+
chats: {
|
| 315 |
+
root: '/data/chat',
|
| 316 |
+
encryption: 'AES-256-GCM via DATA_ENCRYPTION_KEY',
|
| 317 |
+
layout: {
|
| 318 |
+
userIndex: '/data/chat/users/<userId>/index.json (encrypted metadata only)',
|
| 319 |
+
sessionBlob: '/data/chat/users/<userId>/sessions/<sessionId>.json (encrypted full chat)',
|
| 320 |
+
},
|
| 321 |
+
startupBehavior: 'No global chat database decrypt at startup. User data decrypt is lazy and per-user/per-session.',
|
| 322 |
+
},
|
| 323 |
+
media: {
|
| 324 |
+
root: '/data/media',
|
| 325 |
+
encryption: 'AES-256-GCM for blob and index data',
|
| 326 |
+
},
|
| 327 |
+
},
|
| 328 |
+
models: {
|
| 329 |
+
chatSession: {
|
| 330 |
+
id: 'string',
|
| 331 |
+
name: 'string',
|
| 332 |
+
created: 'epoch milliseconds',
|
| 333 |
+
model: 'string|null',
|
| 334 |
+
history: 'array',
|
| 335 |
+
},
|
| 336 |
+
mediaEntry: {
|
| 337 |
+
id: 'string',
|
| 338 |
+
type: 'folder|file',
|
| 339 |
+
name: 'string',
|
| 340 |
+
parentId: 'string|null',
|
| 341 |
+
mimeType: 'string|null',
|
| 342 |
+
kind: 'image|video|audio|text|rich_text|file|null',
|
| 343 |
+
size: 'number',
|
| 344 |
+
sessionIds: 'string[]',
|
| 345 |
+
trashedAt: 'ISO string|null',
|
| 346 |
+
},
|
| 347 |
+
},
|
| 348 |
+
endpoints: [
|
| 349 |
+
{ method: 'GET', path: '/api/db/docs', description: 'This documentation payload.' },
|
| 350 |
+
{ method: 'GET', path: '/api/db/chats', description: 'List chats. Query: includeHistory=0|1 (default 0).' },
|
| 351 |
+
{ method: 'POST', path: '/api/db/chats', description: 'Create a chat. Body: {name?, model?, history?, created?}.' },
|
| 352 |
+
{ method: 'GET', path: '/api/db/chats/:sessionId', description: 'Get one full chat session.' },
|
| 353 |
+
{ method: 'PATCH', path: '/api/db/chats/:sessionId', description: 'Update chat fields: {name?, model?, history?}.' },
|
| 354 |
+
{ method: 'DELETE', path: '/api/db/chats/:sessionId', description: 'Delete a chat.' },
|
| 355 |
+
{ method: 'DELETE', path: '/api/db/chats', description: 'Delete all chats. Body: {confirm:true} required.' },
|
| 356 |
+
{ method: 'GET', path: '/api/db/media', description: 'List all media. Query: view=all|active|trash (default all).' },
|
| 357 |
+
{ method: 'GET', path: '/api/db/media/:id', description: 'Get media metadata by id.' },
|
| 358 |
+
{ method: 'GET', path: '/api/db/media/:id/content', description: 'Get file content. Query: format=base64|text (default base64 for binary).' },
|
| 359 |
+
{ method: 'POST', path: '/api/db/media/files', description: 'Create file from text/base64. Body supports {name,mimeType,parentId,sessionId,kind,text|base64}.' },
|
| 360 |
+
{ method: 'POST', path: '/api/db/media/folders', description: 'Create folder. Body: {name,parentId?}.' },
|
| 361 |
+
{ method: 'PATCH', path: '/api/db/media/:id', description: 'Rename/move media. Body: {name?, parentId?}.' },
|
| 362 |
+
{ method: 'PUT', path: '/api/db/media/:id/content', description: 'Replace file content. Body supports {text|base64,mimeType?,name?,kind?}.' },
|
| 363 |
+
{ method: 'POST', path: '/api/db/media/trash', description: 'Move media to trash. Body: {ids:string[]}.' },
|
| 364 |
+
{ method: 'POST', path: '/api/db/media/restore', description: 'Restore trashed media. Body: {ids:string[]}.' },
|
| 365 |
+
{ method: 'DELETE', path: '/api/db/media', description: 'Delete forever. Body: {ids:string[]}.' },
|
| 366 |
+
{ method: 'GET', path: '/api/db/export', description: 'Export chat + media database for current user. Query includeMediaContent=0|1.' },
|
| 367 |
+
],
|
| 368 |
+
};
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
function requireSignedInMediaOwner(resolved, res, message = 'Sign in to upload files.') {
|
| 372 |
if (resolved?.owner?.type === 'user') return true;
|
| 373 |
res.status(403).json({ error: 'media:auth_required', message });
|
|
|
|
| 391 |
} catch { res.status(500).json({error:'Server error'}); }
|
| 392 |
});
|
| 393 |
|
| 394 |
+
app.get('/api/db/docs', (_req, res) => {
|
| 395 |
+
res.json(buildDatabaseApiDocs());
|
| 396 |
+
});
|
| 397 |
+
|
| 398 |
+
app.get('/api/db/chats', async (req, res) => {
|
| 399 |
+
const resolved = await requireJwtUser(req, res);
|
| 400 |
+
if (!resolved) return;
|
| 401 |
+
try {
|
| 402 |
+
const includeHistory = queryBool(req.query.includeHistory, false);
|
| 403 |
+
const listed = await sessionStore.loadUserSessions(resolved.user.id, resolved.accessToken);
|
| 404 |
+
if (!includeHistory) {
|
| 405 |
+
return res.json({
|
| 406 |
+
items: listed.map((session) => ({
|
| 407 |
+
id: session.id,
|
| 408 |
+
name: session.name,
|
| 409 |
+
created: session.created,
|
| 410 |
+
model: session.model || null,
|
| 411 |
+
})),
|
| 412 |
+
});
|
| 413 |
+
}
|
| 414 |
+
const items = [];
|
| 415 |
+
for (const listedSession of listed) {
|
| 416 |
+
const full = await sessionStore.getUserSessionResolved(resolved.user.id, listedSession.id);
|
| 417 |
+
if (full) items.push(full);
|
| 418 |
+
}
|
| 419 |
+
res.json({ items });
|
| 420 |
+
} catch (err) {
|
| 421 |
+
console.error('db chats list error', err);
|
| 422 |
+
res.status(500).json({ error: 'db:chats_list_failed' });
|
| 423 |
+
}
|
| 424 |
+
});
|
| 425 |
+
|
| 426 |
+
app.post('/api/db/chats', async (req, res) => {
|
| 427 |
+
const resolved = await requireJwtUser(req, res);
|
| 428 |
+
if (!resolved) return;
|
| 429 |
+
try {
|
| 430 |
+
let created = await sessionStore.createUserSession(resolved.user.id, resolved.accessToken);
|
| 431 |
+
const patch = {};
|
| 432 |
+
if (typeof req.body?.name === 'string' && req.body.name.trim()) patch.name = req.body.name.trim();
|
| 433 |
+
if (typeof req.body?.model === 'string' && req.body.model.trim()) patch.model = req.body.model.trim();
|
| 434 |
+
if (Array.isArray(req.body?.history)) patch.history = req.body.history;
|
| 435 |
+
if (Number.isFinite(req.body?.created)) patch.created = req.body.created;
|
| 436 |
+
if (Object.keys(patch).length) {
|
| 437 |
+
created = await sessionStore.updateUserSession(resolved.user.id, resolved.accessToken, created.id, patch);
|
| 438 |
+
}
|
| 439 |
+
res.status(201).json({ item: created });
|
| 440 |
+
} catch (err) {
|
| 441 |
+
console.error('db chat create error', err);
|
| 442 |
+
res.status(500).json({ error: 'db:chat_create_failed' });
|
| 443 |
+
}
|
| 444 |
+
});
|
| 445 |
+
|
| 446 |
+
app.get('/api/db/chats/:sessionId', async (req, res) => {
|
| 447 |
+
const resolved = await requireJwtUser(req, res);
|
| 448 |
+
if (!resolved) return;
|
| 449 |
+
try {
|
| 450 |
+
const session = await sessionStore.getUserSessionResolved(resolved.user.id, req.params.sessionId);
|
| 451 |
+
if (!session) return res.status(404).json({ error: 'db:chat_not_found' });
|
| 452 |
+
res.json({ item: session });
|
| 453 |
+
} catch (err) {
|
| 454 |
+
console.error('db chat get error', err);
|
| 455 |
+
res.status(500).json({ error: 'db:chat_get_failed' });
|
| 456 |
+
}
|
| 457 |
+
});
|
| 458 |
+
|
| 459 |
+
app.patch('/api/db/chats/:sessionId', async (req, res) => {
|
| 460 |
+
const resolved = await requireJwtUser(req, res);
|
| 461 |
+
if (!resolved) return;
|
| 462 |
+
try {
|
| 463 |
+
const patch = {};
|
| 464 |
+
if (typeof req.body?.name === 'string') patch.name = req.body.name.trim() || 'New Chat';
|
| 465 |
+
if (typeof req.body?.model === 'string') patch.model = req.body.model.trim() || null;
|
| 466 |
+
if (req.body?.model === null) patch.model = null;
|
| 467 |
+
if (Array.isArray(req.body?.history)) patch.history = req.body.history;
|
| 468 |
+
if (Number.isFinite(req.body?.created)) patch.created = req.body.created;
|
| 469 |
+
const updated = await sessionStore.updateUserSession(
|
| 470 |
+
resolved.user.id,
|
| 471 |
+
resolved.accessToken,
|
| 472 |
+
req.params.sessionId,
|
| 473 |
+
patch
|
| 474 |
+
);
|
| 475 |
+
if (!updated) return res.status(404).json({ error: 'db:chat_not_found' });
|
| 476 |
+
res.json({ item: updated });
|
| 477 |
+
} catch (err) {
|
| 478 |
+
console.error('db chat patch error', err);
|
| 479 |
+
res.status(500).json({ error: 'db:chat_update_failed' });
|
| 480 |
+
}
|
| 481 |
+
});
|
| 482 |
+
|
| 483 |
+
app.delete('/api/db/chats/:sessionId', async (req, res) => {
|
| 484 |
+
const resolved = await requireJwtUser(req, res);
|
| 485 |
+
if (!resolved) return;
|
| 486 |
+
try {
|
| 487 |
+
const existing = await sessionStore.getUserSessionResolved(resolved.user.id, req.params.sessionId);
|
| 488 |
+
if (!existing) return res.status(404).json({ error: 'db:chat_not_found' });
|
| 489 |
+
await sessionStore.deleteUserSession(resolved.user.id, resolved.accessToken, req.params.sessionId);
|
| 490 |
+
res.json({ ok: true, id: req.params.sessionId });
|
| 491 |
+
} catch (err) {
|
| 492 |
+
console.error('db chat delete error', err);
|
| 493 |
+
res.status(500).json({ error: 'db:chat_delete_failed' });
|
| 494 |
+
}
|
| 495 |
+
});
|
| 496 |
+
|
| 497 |
+
app.delete('/api/db/chats', async (req, res) => {
|
| 498 |
+
const resolved = await requireJwtUser(req, res);
|
| 499 |
+
if (!resolved) return;
|
| 500 |
+
if (req.body?.confirm !== true) {
|
| 501 |
+
return res.status(400).json({
|
| 502 |
+
error: 'db:confirm_required',
|
| 503 |
+
message: 'Send {\"confirm\":true} to delete all chats.',
|
| 504 |
+
});
|
| 505 |
+
}
|
| 506 |
+
try {
|
| 507 |
+
await sessionStore.deleteAllUserSessions(resolved.user.id, resolved.accessToken);
|
| 508 |
+
res.json({ ok: true });
|
| 509 |
+
} catch (err) {
|
| 510 |
+
console.error('db chats delete-all error', err);
|
| 511 |
+
res.status(500).json({ error: 'db:chats_delete_all_failed' });
|
| 512 |
+
}
|
| 513 |
+
});
|
| 514 |
+
|
| 515 |
+
app.get('/api/db/media', async (req, res) => {
|
| 516 |
+
const resolved = await requireJwtUser(req, res);
|
| 517 |
+
if (!resolved) return;
|
| 518 |
+
try {
|
| 519 |
+
const requestedView = String(req.query.view || 'all').toLowerCase();
|
| 520 |
+
const view = ['all', 'active', 'trash'].includes(requestedView) ? requestedView : 'all';
|
| 521 |
+
const result = await mediaStore.listAll(resolved.owner, { view });
|
| 522 |
+
res.json(result);
|
| 523 |
+
} catch (err) {
|
| 524 |
+
console.error('db media list error', err);
|
| 525 |
+
res.status(500).json({ error: 'db:media_list_failed' });
|
| 526 |
+
}
|
| 527 |
+
});
|
| 528 |
+
|
| 529 |
+
app.get('/api/db/media/:id', async (req, res) => {
|
| 530 |
+
const resolved = await requireJwtUser(req, res);
|
| 531 |
+
if (!resolved) return;
|
| 532 |
+
try {
|
| 533 |
+
const item = await mediaStore.get(resolved.owner, req.params.id);
|
| 534 |
+
if (!item) return res.status(404).json({ error: 'db:media_not_found' });
|
| 535 |
+
res.json({ item });
|
| 536 |
+
} catch (err) {
|
| 537 |
+
console.error('db media get error', err);
|
| 538 |
+
res.status(500).json({ error: 'db:media_get_failed' });
|
| 539 |
+
}
|
| 540 |
+
});
|
| 541 |
+
|
| 542 |
+
app.get('/api/db/media/:id/content', async (req, res) => {
|
| 543 |
+
const resolved = await requireJwtUser(req, res);
|
| 544 |
+
if (!resolved) return;
|
| 545 |
+
try {
|
| 546 |
+
const loaded = await mediaStore.readBuffer(resolved.owner, req.params.id);
|
| 547 |
+
if (!loaded) return res.status(404).json({ error: 'db:media_not_found' });
|
| 548 |
+
const format = String(req.query.format || '').toLowerCase();
|
| 549 |
+
if (format === 'text' || (loaded.entry.mimeType || '').startsWith('text/')) {
|
| 550 |
+
return res.json({
|
| 551 |
+
item: loaded.entry,
|
| 552 |
+
encoding: 'utf8',
|
| 553 |
+
content: loaded.buffer.toString('utf8'),
|
| 554 |
+
});
|
| 555 |
+
}
|
| 556 |
+
res.json({
|
| 557 |
+
item: loaded.entry,
|
| 558 |
+
encoding: 'base64',
|
| 559 |
+
content: loaded.buffer.toString('base64'),
|
| 560 |
+
});
|
| 561 |
+
} catch (err) {
|
| 562 |
+
console.error('db media content error', err);
|
| 563 |
+
res.status(500).json({ error: 'db:media_content_failed' });
|
| 564 |
+
}
|
| 565 |
+
});
|
| 566 |
+
|
| 567 |
+
app.post('/api/db/media/files', async (req, res) => {
|
| 568 |
+
const resolved = await requireJwtUser(req, res);
|
| 569 |
+
if (!resolved) return;
|
| 570 |
+
try {
|
| 571 |
+
const text = typeof req.body?.text === 'string' ? req.body.text : null;
|
| 572 |
+
const base64 = typeof req.body?.base64 === 'string' ? req.body.base64 : null;
|
| 573 |
+
if (text === null && base64 === null) {
|
| 574 |
+
return res.status(400).json({
|
| 575 |
+
error: 'db:content_required',
|
| 576 |
+
message: 'Provide either text or base64 in request body.',
|
| 577 |
+
});
|
| 578 |
+
}
|
| 579 |
+
const buffer = text !== null ? Buffer.from(text, 'utf8') : Buffer.from(base64, 'base64');
|
| 580 |
+
const item = await mediaStore.storeBuffer(resolved.owner, {
|
| 581 |
+
name: req.body?.name || 'upload.bin',
|
| 582 |
+
mimeType: req.body?.mimeType || 'application/octet-stream',
|
| 583 |
+
buffer,
|
| 584 |
+
parentId: req.body?.parentId || null,
|
| 585 |
+
sessionId: req.body?.sessionId || null,
|
| 586 |
+
source: req.body?.source || 'api_db',
|
| 587 |
+
kind: req.body?.kind || null,
|
| 588 |
+
});
|
| 589 |
+
const usage = await mediaStore.getUsage(resolved.owner);
|
| 590 |
+
res.status(201).json({ item, usage });
|
| 591 |
+
} catch (err) {
|
| 592 |
+
console.error('db media create file error', err);
|
| 593 |
+
res.status(err.status || 500).json({
|
| 594 |
+
error: err.code || 'db:media_create_file_failed',
|
| 595 |
+
message: err.message || 'Failed to create file.',
|
| 596 |
+
usage: err.usage || null,
|
| 597 |
+
});
|
| 598 |
+
}
|
| 599 |
+
});
|
| 600 |
+
|
| 601 |
+
app.post('/api/db/media/folders', async (req, res) => {
|
| 602 |
+
const resolved = await requireJwtUser(req, res);
|
| 603 |
+
if (!resolved) return;
|
| 604 |
+
try {
|
| 605 |
+
const item = await mediaStore.createFolder(resolved.owner, {
|
| 606 |
+
name: req.body?.name || 'New Folder',
|
| 607 |
+
parentId: req.body?.parentId || null,
|
| 608 |
+
});
|
| 609 |
+
const usage = await mediaStore.getUsage(resolved.owner);
|
| 610 |
+
res.status(201).json({ item, usage });
|
| 611 |
+
} catch (err) {
|
| 612 |
+
console.error('db media create folder error', err);
|
| 613 |
+
res.status(500).json({ error: 'db:media_create_folder_failed' });
|
| 614 |
+
}
|
| 615 |
+
});
|
| 616 |
+
|
| 617 |
+
app.patch('/api/db/media/:id', async (req, res) => {
|
| 618 |
+
const resolved = await requireJwtUser(req, res);
|
| 619 |
+
if (!resolved) return;
|
| 620 |
+
try {
|
| 621 |
+
const updates = [];
|
| 622 |
+
if (typeof req.body?.parentId !== 'undefined') {
|
| 623 |
+
const moved = await mediaStore.move(
|
| 624 |
+
resolved.owner,
|
| 625 |
+
[req.params.id],
|
| 626 |
+
req.body?.parentId || null
|
| 627 |
+
);
|
| 628 |
+
if (!moved.length) return res.status(404).json({ error: 'db:media_not_found_or_move_failed' });
|
| 629 |
+
updates.push(...moved);
|
| 630 |
+
}
|
| 631 |
+
if (typeof req.body?.name === 'string') {
|
| 632 |
+
const renamed = await mediaStore.rename(resolved.owner, req.params.id, req.body.name);
|
| 633 |
+
if (!renamed) return res.status(404).json({ error: 'db:media_not_found' });
|
| 634 |
+
updates.push(renamed);
|
| 635 |
+
}
|
| 636 |
+
const item = await mediaStore.get(resolved.owner, req.params.id);
|
| 637 |
+
if (!item) return res.status(404).json({ error: 'db:media_not_found' });
|
| 638 |
+
const usage = await mediaStore.getUsage(resolved.owner);
|
| 639 |
+
res.json({ item, updates, usage });
|
| 640 |
+
} catch (err) {
|
| 641 |
+
console.error('db media patch error', err);
|
| 642 |
+
res.status(500).json({ error: 'db:media_update_failed', message: err.message || 'Update failed' });
|
| 643 |
+
}
|
| 644 |
+
});
|
| 645 |
+
|
| 646 |
+
app.put('/api/db/media/:id/content', async (req, res) => {
|
| 647 |
+
const resolved = await requireJwtUser(req, res);
|
| 648 |
+
if (!resolved) return;
|
| 649 |
+
try {
|
| 650 |
+
const text = typeof req.body?.text === 'string' ? req.body.text : null;
|
| 651 |
+
const base64 = typeof req.body?.base64 === 'string' ? req.body.base64 : null;
|
| 652 |
+
if (text === null && base64 === null) {
|
| 653 |
+
return res.status(400).json({
|
| 654 |
+
error: 'db:content_required',
|
| 655 |
+
message: 'Provide either text or base64 in request body.',
|
| 656 |
+
});
|
| 657 |
+
}
|
| 658 |
+
const buffer = text !== null ? Buffer.from(text, 'utf8') : Buffer.from(base64, 'base64');
|
| 659 |
+
const item = await mediaStore.updateContent(resolved.owner, req.params.id, {
|
| 660 |
+
buffer,
|
| 661 |
+
name: typeof req.body?.name === 'string' ? req.body.name : null,
|
| 662 |
+
mimeType: typeof req.body?.mimeType === 'string' ? req.body.mimeType : null,
|
| 663 |
+
kind: typeof req.body?.kind === 'string' ? req.body.kind : null,
|
| 664 |
+
});
|
| 665 |
+
if (!item) return res.status(404).json({ error: 'db:media_not_found' });
|
| 666 |
+
const usage = await mediaStore.getUsage(resolved.owner);
|
| 667 |
+
res.json({ item, usage });
|
| 668 |
+
} catch (err) {
|
| 669 |
+
console.error('db media content update error', err);
|
| 670 |
+
res.status(err.status || 500).json({
|
| 671 |
+
error: err.code || 'db:media_content_update_failed',
|
| 672 |
+
message: err.message || 'Update failed',
|
| 673 |
+
usage: err.usage || null,
|
| 674 |
+
});
|
| 675 |
+
}
|
| 676 |
+
});
|
| 677 |
+
|
| 678 |
+
app.post('/api/db/media/trash', async (req, res) => {
|
| 679 |
+
const resolved = await requireJwtUser(req, res);
|
| 680 |
+
if (!resolved) return;
|
| 681 |
+
try {
|
| 682 |
+
const ids = ensureStringArray(req.body?.ids);
|
| 683 |
+
const items = await mediaStore.moveToTrash(resolved.owner, ids);
|
| 684 |
+
const usage = await mediaStore.getUsage(resolved.owner);
|
| 685 |
+
res.json({ items, usage });
|
| 686 |
+
} catch (err) {
|
| 687 |
+
console.error('db media trash error', err);
|
| 688 |
+
res.status(500).json({ error: 'db:media_trash_failed' });
|
| 689 |
+
}
|
| 690 |
+
});
|
| 691 |
+
|
| 692 |
+
app.post('/api/db/media/restore', async (req, res) => {
|
| 693 |
+
const resolved = await requireJwtUser(req, res);
|
| 694 |
+
if (!resolved) return;
|
| 695 |
+
try {
|
| 696 |
+
const ids = ensureStringArray(req.body?.ids);
|
| 697 |
+
const items = await mediaStore.restore(resolved.owner, ids);
|
| 698 |
+
const usage = await mediaStore.getUsage(resolved.owner);
|
| 699 |
+
res.json({ items, usage });
|
| 700 |
+
} catch (err) {
|
| 701 |
+
console.error('db media restore error', err);
|
| 702 |
+
res.status(500).json({ error: 'db:media_restore_failed' });
|
| 703 |
+
}
|
| 704 |
+
});
|
| 705 |
+
|
| 706 |
+
app.delete('/api/db/media', async (req, res) => {
|
| 707 |
+
const resolved = await requireJwtUser(req, res);
|
| 708 |
+
if (!resolved) return;
|
| 709 |
+
try {
|
| 710 |
+
const ids = ensureStringArray(req.body?.ids);
|
| 711 |
+
const removedIds = await mediaStore.deleteForever(resolved.owner, ids);
|
| 712 |
+
const usage = await mediaStore.getUsage(resolved.owner);
|
| 713 |
+
res.json({ ids: removedIds, usage });
|
| 714 |
+
} catch (err) {
|
| 715 |
+
console.error('db media delete error', err);
|
| 716 |
+
res.status(500).json({ error: 'db:media_delete_failed' });
|
| 717 |
+
}
|
| 718 |
+
});
|
| 719 |
+
|
| 720 |
+
app.get('/api/db/export', async (req, res) => {
|
| 721 |
+
const resolved = await requireJwtUser(req, res);
|
| 722 |
+
if (!resolved) return;
|
| 723 |
+
try {
|
| 724 |
+
const includeMediaContent = queryBool(req.query.includeMediaContent, false);
|
| 725 |
+
const listed = await sessionStore.loadUserSessions(resolved.user.id, resolved.accessToken);
|
| 726 |
+
const chats = [];
|
| 727 |
+
for (const listedSession of listed) {
|
| 728 |
+
const full = await sessionStore.getUserSessionResolved(resolved.user.id, listedSession.id);
|
| 729 |
+
if (full) chats.push(full);
|
| 730 |
+
}
|
| 731 |
+
|
| 732 |
+
const mediaResult = await mediaStore.listAll(resolved.owner, { view: 'all' });
|
| 733 |
+
let media = mediaResult.items;
|
| 734 |
+
if (includeMediaContent) {
|
| 735 |
+
const withContent = [];
|
| 736 |
+
for (const item of mediaResult.items) {
|
| 737 |
+
if (item.type !== 'file') {
|
| 738 |
+
withContent.push(item);
|
| 739 |
+
continue;
|
| 740 |
+
}
|
| 741 |
+
const loaded = await mediaStore.readBuffer(resolved.owner, item.id);
|
| 742 |
+
withContent.push({
|
| 743 |
+
...item,
|
| 744 |
+
contentEncoding: 'base64',
|
| 745 |
+
content: loaded?.buffer ? loaded.buffer.toString('base64') : null,
|
| 746 |
+
});
|
| 747 |
+
}
|
| 748 |
+
media = withContent;
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
res.json({
|
| 752 |
+
userId: resolved.user.id,
|
| 753 |
+
exportedAt: new Date().toISOString(),
|
| 754 |
+
chats,
|
| 755 |
+
media,
|
| 756 |
+
usage: mediaResult.usage,
|
| 757 |
+
});
|
| 758 |
+
} catch (err) {
|
| 759 |
+
console.error('db export error', err);
|
| 760 |
+
res.status(500).json({ error: 'db:export_failed' });
|
| 761 |
+
}
|
| 762 |
+
});
|
| 763 |
+
|
| 764 |
app.get('/api/media', async (req, res) => {
|
| 765 |
const resolved = await requireRequestOwner(req, res);
|
| 766 |
if (!resolved) return;
|
server/mediaStore.js
CHANGED
|
@@ -270,6 +270,21 @@ export const mediaStore = {
|
|
| 270 |
return sanitizeEntry(entry);
|
| 271 |
},
|
| 272 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
async storeBuffer(owner, {
|
| 274 |
name,
|
| 275 |
mimeType,
|
|
|
|
| 270 |
return sanitizeEntry(entry);
|
| 271 |
},
|
| 272 |
|
| 273 |
+
async listAll(owner, { view = 'all' } = {}) {
|
| 274 |
+
ensureOwner(owner);
|
| 275 |
+
await ensureLoaded();
|
| 276 |
+
const items = Object.values(state.index.entries)
|
| 277 |
+
.filter((entry) => canAccess(entry, owner))
|
| 278 |
+
.filter((entry) => {
|
| 279 |
+
if (view === 'active') return !entry.trashedAt;
|
| 280 |
+
if (view === 'trash') return !!entry.trashedAt;
|
| 281 |
+
return true;
|
| 282 |
+
})
|
| 283 |
+
.sort((a, b) => new Date(b.updatedAt || b.createdAt).getTime() - new Date(a.updatedAt || a.createdAt).getTime())
|
| 284 |
+
.map(sanitizeEntry);
|
| 285 |
+
return { items, usage: computeUsage(owner) };
|
| 286 |
+
},
|
| 287 |
+
|
| 288 |
async storeBuffer(owner, {
|
| 289 |
name,
|
| 290 |
mimeType,
|
server/sessionStore.js
CHANGED
|
@@ -1,34 +1,208 @@
|
|
| 1 |
-
// sessionStore.js — access_token + Supabase RLS, no service role key needed.
|
| 2 |
-
// Device sessions live in memory only (restart clears them).
|
| 3 |
-
import { createClient } from '@supabase/supabase-js';
|
| 4 |
import crypto from 'crypto';
|
| 5 |
-
import
|
| 6 |
import path from 'path';
|
|
|
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
|
|
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
const TEMP_INACTIVITY = 12 * 60 * 60 * 1000;
|
| 13 |
-
const TEMP_MSG_LIMIT
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
|
| 16 |
-
const
|
| 17 |
-
|
| 18 |
|
| 19 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
async function loadTempStore() {
|
| 22 |
-
const data = await loadEncryptedJson(TEMP_STORE_FILE);
|
| 23 |
-
if (data)
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
}
|
| 32 |
}
|
| 33 |
}
|
| 34 |
|
|
@@ -42,51 +216,67 @@ async function saveTempStore() {
|
|
| 42 |
lastActive: d.lastActive,
|
| 43 |
};
|
| 44 |
}
|
| 45 |
-
await saveEncryptedJson(TEMP_STORE_FILE, data);
|
| 46 |
}
|
| 47 |
|
| 48 |
-
|
| 49 |
-
loadTempStore().catch(err => console.error('Failed to load temp store:', err));
|
| 50 |
|
| 51 |
setInterval(async () => {
|
| 52 |
const now = Date.now();
|
| 53 |
-
for (const [id, d] of tempStore)
|
| 54 |
-
if (now - d.created > TEMP_TTL_MS || now - d.lastActive > TEMP_INACTIVITY)
|
| 55 |
tempStore.delete(id);
|
| 56 |
-
|
| 57 |
-
|
|
|
|
| 58 |
}, 30 * 60 * 1000);
|
| 59 |
|
| 60 |
-
function userClient(accessToken) {
|
| 61 |
-
return createClient(_SUPABASE_URL, _SUPABASE_ANON_KEY, {
|
| 62 |
-
global: { headers: { Authorization: `Bearer ${accessToken}` } },
|
| 63 |
-
auth: { persistSession: false },
|
| 64 |
-
});
|
| 65 |
-
}
|
| 66 |
-
|
| 67 |
export const sessionStore = {
|
| 68 |
-
//
|
| 69 |
initTemp(t) {
|
| 70 |
-
if (!tempStore.has(t))
|
| 71 |
-
tempStore.set(t, {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
return tempStore.get(t);
|
| 73 |
},
|
| 74 |
-
tempCanSend(t)
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
createTempSession(t) {
|
| 79 |
const d = this.initTemp(t);
|
| 80 |
const s = { id: crypto.randomUUID(), name: 'New Chat', created: Date.now(), history: [] };
|
| 81 |
-
d.sessions.set(s.id, s);
|
| 82 |
-
|
|
|
|
| 83 |
return s;
|
| 84 |
},
|
| 85 |
updateTempSession(t, id, patch) {
|
| 86 |
-
const d = tempStore.get(t);
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
| 90 |
return s;
|
| 91 |
},
|
| 92 |
restoreTempSession(t, session) {
|
|
@@ -94,151 +284,211 @@ export const sessionStore = {
|
|
| 94 |
const restored = JSON.parse(JSON.stringify(session));
|
| 95 |
d.sessions.set(restored.id, restored);
|
| 96 |
d.lastActive = Date.now();
|
| 97 |
-
saveTempStore().catch(err => console.error('Failed to save temp store:', err));
|
| 98 |
return restored;
|
| 99 |
},
|
| 100 |
-
deleteTempSession(t, id) {
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
deleteTempSessionEverywhere(id) {
|
| 103 |
let changed = false;
|
| 104 |
for (const temp of tempStore.values()) {
|
| 105 |
if (temp.sessions.delete(id)) changed = true;
|
| 106 |
}
|
| 107 |
-
if (changed) saveTempStore().catch(err => console.error('Failed to save temp store:', err));
|
| 108 |
return changed;
|
| 109 |
},
|
| 110 |
|
| 111 |
-
|
| 112 |
-
* Copy temp sessions into the user's account on login.
|
| 113 |
-
* We intentionally do NOT delete from tempStore so the guest session
|
| 114 |
-
* remains usable if the user logs out again (and so the WS client's
|
| 115 |
-
* tempId still resolves while the tab is open).
|
| 116 |
-
* Sessions that already exist in the user account (same id) are skipped
|
| 117 |
-
* to avoid overwriting newer server data.
|
| 118 |
-
*/
|
| 119 |
-
async transferTempToUser(tempId, userId, accessToken) {
|
| 120 |
const d = tempStore.get(tempId);
|
| 121 |
if (!d || !d.sessions.size) return;
|
| 122 |
|
| 123 |
-
|
| 124 |
-
const user = this._ensureUser(userId);
|
| 125 |
|
| 126 |
for (const s of d.sessions.values()) {
|
| 127 |
-
// Skip sessions that are empty (never actually used)
|
| 128 |
if (!s.history || s.history.length === 0) continue;
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
}
|
| 139 |
},
|
| 140 |
|
| 141 |
-
//
|
| 142 |
_ensureUser(uid) {
|
| 143 |
-
|
| 144 |
-
return userCache.get(uid);
|
| 145 |
-
},
|
| 146 |
-
async loadUserSessions(userId, accessToken) {
|
| 147 |
-
const uc = userClient(accessToken);
|
| 148 |
-
const { data, error } = await uc.from('web_sessions').select('*')
|
| 149 |
-
.eq('user_id', userId).order('updated_at', { ascending: false });
|
| 150 |
-
if (error) { console.error('loadUserSessions', error.message); return []; }
|
| 151 |
-
const user = this._ensureUser(userId);
|
| 152 |
-
for (const row of data || [])
|
| 153 |
-
user.sessions.set(row.id, { id: row.id, name: row.name,
|
| 154 |
-
created: new Date(row.created_at).getTime(), history: row.history || [], model: row.model });
|
| 155 |
-
return [...user.sessions.values()];
|
| 156 |
-
},
|
| 157 |
-
getUserSessions(uid) { return [...(userCache.get(uid)?.sessions.values() || [])]; },
|
| 158 |
-
getUserSession(uid, id) { return userCache.get(uid)?.sessions.get(id) || null; },
|
| 159 |
-
async createUserSession(userId, accessToken) {
|
| 160 |
-
const s = { id: crypto.randomUUID(), name: 'New Chat', created: Date.now(), history: [] };
|
| 161 |
-
this._ensureUser(userId).sessions.set(s.id, s);
|
| 162 |
-
await this._persist(userClient(accessToken), userId, s).catch(() => {});
|
| 163 |
-
return s;
|
| 164 |
},
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
|
|
|
| 170 |
},
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
const
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
return s;
|
| 177 |
},
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
}
|
|
|
|
| 190 |
},
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
|
|
|
| 208 |
},
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
});
|
| 215 |
},
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
const
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
});
|
| 226 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
async resolveShareToken(token) {
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
const { data } = await uc.from('shared_sessions').select('*').eq('token', token).single();
|
| 232 |
-
return data || null;
|
| 233 |
},
|
|
|
|
| 234 |
async importSharedSession(userId, accessToken, token) {
|
| 235 |
-
const shared = await this.resolveShareToken(token);
|
| 236 |
-
|
| 237 |
-
const
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
| 242 |
return newSession;
|
| 243 |
},
|
| 244 |
};
|
|
@@ -246,17 +496,37 @@ export const sessionStore = {
|
|
| 246 |
export const deviceSessionStore = {
|
| 247 |
create(userId, ip, userAgent) {
|
| 248 |
const token = crypto.randomBytes(32).toString('hex');
|
| 249 |
-
devSessions.set(token, {
|
| 250 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
return token;
|
| 252 |
},
|
| 253 |
-
getForUser(uid)
|
| 254 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
revokeAllExcept(uid, except) {
|
| 256 |
-
for (const [t, s] of devSessions)
|
|
|
|
|
|
|
| 257 |
},
|
| 258 |
-
validate(token)
|
| 259 |
-
const s = devSessions.get(token);
|
| 260 |
-
s
|
|
|
|
|
|
|
| 261 |
},
|
| 262 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import crypto from 'crypto';
|
| 2 |
+
import fs from 'fs/promises';
|
| 3 |
import path from 'path';
|
| 4 |
+
import { loadEncryptedJson, saveEncryptedJson } from './cryptoUtils.js';
|
| 5 |
|
| 6 |
+
// Local encrypted chat storage.
|
| 7 |
+
// Chats are stored per user in /data/chat/users/<userId>/sessions/<sessionId>.json (encrypted),
|
| 8 |
+
// with a lightweight encrypted index for fast listing.
|
| 9 |
|
| 10 |
+
let _SUPABASE_URL;
|
| 11 |
+
let _SUPABASE_ANON_KEY;
|
| 12 |
+
export function initStoreConfig(url, key) {
|
| 13 |
+
_SUPABASE_URL = url;
|
| 14 |
+
_SUPABASE_ANON_KEY = key;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
const TEMP_TTL_MS = 24 * 60 * 60 * 1000;
|
| 18 |
const TEMP_INACTIVITY = 12 * 60 * 60 * 1000;
|
| 19 |
+
const TEMP_MSG_LIMIT = 10;
|
| 20 |
+
|
| 21 |
+
const DATA_ROOT = '/data/chat';
|
| 22 |
+
const USERS_ROOT = path.join(DATA_ROOT, 'users');
|
| 23 |
+
const SHARES_FILE = path.join(DATA_ROOT, 'shares', 'index.json');
|
| 24 |
+
const TEMP_STORE_FILE = path.join(DATA_ROOT, 'temp_sessions.json');
|
| 25 |
+
|
| 26 |
+
const userCache = new Map(); // userId -> UserState
|
| 27 |
+
const tempStore = new Map(); // tempId -> TempData
|
| 28 |
+
const devSessions = new Map(); // token -> DeviceSession
|
| 29 |
+
const userWriteLocks = new Map(); // userId -> Promise
|
| 30 |
+
|
| 31 |
+
const shareState = {
|
| 32 |
+
loaded: false,
|
| 33 |
+
index: {
|
| 34 |
+
shares: {},
|
| 35 |
+
},
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
function nowIso() {
|
| 39 |
+
return new Date().toISOString();
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function safeFileId(value, fallback = 'unknown') {
|
| 43 |
+
const normalized = String(value || '').trim();
|
| 44 |
+
if (!normalized) return fallback;
|
| 45 |
+
return normalized.replace(/[^a-zA-Z0-9_.-]+/g, '_').slice(0, 160) || fallback;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function ensureSessionShape(raw, fallbackId = null) {
|
| 49 |
+
const created = Number.isFinite(raw?.created) ? raw.created : Date.now();
|
| 50 |
+
return {
|
| 51 |
+
id: raw?.id || fallbackId || crypto.randomUUID(),
|
| 52 |
+
name: String(raw?.name || 'New Chat'),
|
| 53 |
+
created,
|
| 54 |
+
history: Array.isArray(raw?.history) ? raw.history : [],
|
| 55 |
+
model: raw?.model || null,
|
| 56 |
+
};
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
function buildSessionMeta(session, existingMeta = null) {
|
| 60 |
+
const created = Number.isFinite(session?.created)
|
| 61 |
+
? session.created
|
| 62 |
+
: (Number.isFinite(existingMeta?.created) ? existingMeta.created : Date.now());
|
| 63 |
+
return {
|
| 64 |
+
id: session.id,
|
| 65 |
+
name: String(session.name || existingMeta?.name || 'New Chat'),
|
| 66 |
+
created,
|
| 67 |
+
model: session.model || existingMeta?.model || null,
|
| 68 |
+
updatedAt: nowIso(),
|
| 69 |
+
};
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
function ensureUserState(userId) {
|
| 73 |
+
if (!userCache.has(userId)) {
|
| 74 |
+
userCache.set(userId, {
|
| 75 |
+
indexLoaded: false,
|
| 76 |
+
sessionsMeta: new Map(),
|
| 77 |
+
loadedSessions: new Map(),
|
| 78 |
+
online: new Set(),
|
| 79 |
+
});
|
| 80 |
+
}
|
| 81 |
+
return userCache.get(userId);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
function userDir(userId) {
|
| 85 |
+
return path.join(USERS_ROOT, safeFileId(userId));
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
function userIndexFile(userId) {
|
| 89 |
+
return path.join(userDir(userId), 'index.json');
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
function userSessionFile(userId, sessionId) {
|
| 93 |
+
return path.join(userDir(userId), 'sessions', `${safeFileId(sessionId)}.json`);
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
function userIndexAad(userId) {
|
| 97 |
+
return `chat:user:${userId}:index`;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
function userSessionAad(userId, sessionId) {
|
| 101 |
+
return `chat:user:${userId}:session:${sessionId}`;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
function toSerializableSessionMetaMap(map) {
|
| 105 |
+
const sessions = {};
|
| 106 |
+
for (const [id, meta] of map.entries()) {
|
| 107 |
+
sessions[id] = {
|
| 108 |
+
id,
|
| 109 |
+
name: String(meta?.name || 'New Chat'),
|
| 110 |
+
created: Number.isFinite(meta?.created) ? meta.created : Date.now(),
|
| 111 |
+
model: meta?.model || null,
|
| 112 |
+
updatedAt: meta?.updatedAt || nowIso(),
|
| 113 |
+
};
|
| 114 |
+
}
|
| 115 |
+
return sessions;
|
| 116 |
+
}
|
| 117 |
|
| 118 |
+
async function ensureUserIndexLoaded(userId) {
|
| 119 |
+
const state = ensureUserState(userId);
|
| 120 |
+
if (state.indexLoaded) return state;
|
| 121 |
|
| 122 |
+
const stored = await loadEncryptedJson(userIndexFile(userId), userIndexAad(userId));
|
| 123 |
+
state.sessionsMeta.clear();
|
| 124 |
+
|
| 125 |
+
const sessions = stored?.sessions || {};
|
| 126 |
+
for (const [id, meta] of Object.entries(sessions)) {
|
| 127 |
+
state.sessionsMeta.set(id, {
|
| 128 |
+
id,
|
| 129 |
+
name: String(meta?.name || 'New Chat'),
|
| 130 |
+
created: Number.isFinite(meta?.created) ? meta.created : Date.now(),
|
| 131 |
+
model: meta?.model || null,
|
| 132 |
+
updatedAt: meta?.updatedAt || nowIso(),
|
| 133 |
+
});
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
state.indexLoaded = true;
|
| 137 |
+
return state;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
async function saveUserIndex(userId) {
|
| 141 |
+
const state = ensureUserState(userId);
|
| 142 |
+
const payload = {
|
| 143 |
+
sessions: toSerializableSessionMetaMap(state.sessionsMeta),
|
| 144 |
+
};
|
| 145 |
+
await saveEncryptedJson(userIndexFile(userId), payload, userIndexAad(userId));
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
async function loadUserSessionFromDisk(userId, sessionId) {
|
| 149 |
+
const raw = await loadEncryptedJson(userSessionFile(userId, sessionId), userSessionAad(userId, sessionId));
|
| 150 |
+
if (!raw) return null;
|
| 151 |
+
return ensureSessionShape(raw, sessionId);
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
async function saveUserSessionToDisk(userId, session) {
|
| 155 |
+
const shaped = ensureSessionShape(session);
|
| 156 |
+
await saveEncryptedJson(userSessionFile(userId, shaped.id), shaped, userSessionAad(userId, shaped.id));
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
async function deleteUserSessionFromDisk(userId, sessionId) {
|
| 160 |
+
await fs.rm(userSessionFile(userId, sessionId), { force: true }).catch(() => {});
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
async function withUserWriteLock(userId, fn) {
|
| 164 |
+
const prior = userWriteLocks.get(userId) || Promise.resolve();
|
| 165 |
+
const next = prior.catch(() => {}).then(fn);
|
| 166 |
+
userWriteLocks.set(userId, next.finally(() => {
|
| 167 |
+
if (userWriteLocks.get(userId) === next) userWriteLocks.delete(userId);
|
| 168 |
+
}));
|
| 169 |
+
return next;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
function sessionForList(meta, loaded) {
|
| 173 |
+
const source = loaded || meta;
|
| 174 |
+
return {
|
| 175 |
+
id: source.id,
|
| 176 |
+
name: source.name || 'New Chat',
|
| 177 |
+
created: Number.isFinite(source.created) ? source.created : Date.now(),
|
| 178 |
+
history: loaded?.history || [],
|
| 179 |
+
model: source.model || null,
|
| 180 |
+
};
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
async function ensureShareIndexLoaded() {
|
| 184 |
+
if (shareState.loaded) return;
|
| 185 |
+
const stored = await loadEncryptedJson(SHARES_FILE, 'chat:shares:index');
|
| 186 |
+
shareState.index = {
|
| 187 |
+
shares: stored?.shares || {},
|
| 188 |
+
};
|
| 189 |
+
shareState.loaded = true;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
async function saveShareIndex() {
|
| 193 |
+
await saveEncryptedJson(SHARES_FILE, shareState.index, 'chat:shares:index');
|
| 194 |
+
}
|
| 195 |
|
| 196 |
async function loadTempStore() {
|
| 197 |
+
const data = await loadEncryptedJson(TEMP_STORE_FILE, 'chat:temp:index');
|
| 198 |
+
if (!data) return;
|
| 199 |
+
for (const [id, d] of Object.entries(data)) {
|
| 200 |
+
tempStore.set(id, {
|
| 201 |
+
sessions: new Map(Object.entries(d.sessions || {})),
|
| 202 |
+
msgCount: d.msgCount || 0,
|
| 203 |
+
created: d.created || Date.now(),
|
| 204 |
+
lastActive: d.lastActive || Date.now(),
|
| 205 |
+
});
|
|
|
|
| 206 |
}
|
| 207 |
}
|
| 208 |
|
|
|
|
| 216 |
lastActive: d.lastActive,
|
| 217 |
};
|
| 218 |
}
|
| 219 |
+
await saveEncryptedJson(TEMP_STORE_FILE, data, 'chat:temp:index');
|
| 220 |
}
|
| 221 |
|
| 222 |
+
loadTempStore().catch((err) => console.error('Failed to load temp store:', err));
|
|
|
|
| 223 |
|
| 224 |
setInterval(async () => {
|
| 225 |
const now = Date.now();
|
| 226 |
+
for (const [id, d] of tempStore) {
|
| 227 |
+
if (now - d.created > TEMP_TTL_MS || now - d.lastActive > TEMP_INACTIVITY) {
|
| 228 |
tempStore.delete(id);
|
| 229 |
+
}
|
| 230 |
+
}
|
| 231 |
+
await saveTempStore().catch((err) => console.error('Failed to save temp store:', err));
|
| 232 |
}, 30 * 60 * 1000);
|
| 233 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
export const sessionStore = {
|
| 235 |
+
// TEMP
|
| 236 |
initTemp(t) {
|
| 237 |
+
if (!tempStore.has(t)) {
|
| 238 |
+
tempStore.set(t, {
|
| 239 |
+
sessions: new Map(),
|
| 240 |
+
msgCount: 0,
|
| 241 |
+
created: Date.now(),
|
| 242 |
+
lastActive: Date.now(),
|
| 243 |
+
});
|
| 244 |
+
}
|
| 245 |
return tempStore.get(t);
|
| 246 |
},
|
| 247 |
+
tempCanSend(t) {
|
| 248 |
+
const d = tempStore.get(t);
|
| 249 |
+
return d ? d.msgCount < TEMP_MSG_LIMIT : false;
|
| 250 |
+
},
|
| 251 |
+
tempBump(t) {
|
| 252 |
+
const d = tempStore.get(t);
|
| 253 |
+
if (d) {
|
| 254 |
+
d.msgCount++;
|
| 255 |
+
d.lastActive = Date.now();
|
| 256 |
+
}
|
| 257 |
+
},
|
| 258 |
+
getTempSessions(t) {
|
| 259 |
+
return [...(tempStore.get(t)?.sessions.values() || [])];
|
| 260 |
+
},
|
| 261 |
+
getTempSession(t, id) {
|
| 262 |
+
return tempStore.get(t)?.sessions.get(id) || null;
|
| 263 |
+
},
|
| 264 |
createTempSession(t) {
|
| 265 |
const d = this.initTemp(t);
|
| 266 |
const s = { id: crypto.randomUUID(), name: 'New Chat', created: Date.now(), history: [] };
|
| 267 |
+
d.sessions.set(s.id, s);
|
| 268 |
+
d.lastActive = Date.now();
|
| 269 |
+
saveTempStore().catch((err) => console.error('Failed to save temp store:', err));
|
| 270 |
return s;
|
| 271 |
},
|
| 272 |
updateTempSession(t, id, patch) {
|
| 273 |
+
const d = tempStore.get(t);
|
| 274 |
+
if (!d) return null;
|
| 275 |
+
const s = d.sessions.get(id);
|
| 276 |
+
if (!s) return null;
|
| 277 |
+
Object.assign(s, patch);
|
| 278 |
+
d.lastActive = Date.now();
|
| 279 |
+
saveTempStore().catch((err) => console.error('Failed to save temp store:', err));
|
| 280 |
return s;
|
| 281 |
},
|
| 282 |
restoreTempSession(t, session) {
|
|
|
|
| 284 |
const restored = JSON.parse(JSON.stringify(session));
|
| 285 |
d.sessions.set(restored.id, restored);
|
| 286 |
d.lastActive = Date.now();
|
| 287 |
+
saveTempStore().catch((err) => console.error('Failed to save temp store:', err));
|
| 288 |
return restored;
|
| 289 |
},
|
| 290 |
+
deleteTempSession(t, id) {
|
| 291 |
+
tempStore.get(t)?.sessions.delete(id);
|
| 292 |
+
saveTempStore().catch((err) => console.error('Failed to save temp store:', err));
|
| 293 |
+
},
|
| 294 |
+
deleteTempAll(t) {
|
| 295 |
+
tempStore.get(t)?.sessions.clear();
|
| 296 |
+
saveTempStore().catch((err) => console.error('Failed to save temp store:', err));
|
| 297 |
+
},
|
| 298 |
deleteTempSessionEverywhere(id) {
|
| 299 |
let changed = false;
|
| 300 |
for (const temp of tempStore.values()) {
|
| 301 |
if (temp.sessions.delete(id)) changed = true;
|
| 302 |
}
|
| 303 |
+
if (changed) saveTempStore().catch((err) => console.error('Failed to save temp store:', err));
|
| 304 |
return changed;
|
| 305 |
},
|
| 306 |
|
| 307 |
+
async transferTempToUser(tempId, userId, _accessToken) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
const d = tempStore.get(tempId);
|
| 309 |
if (!d || !d.sessions.size) return;
|
| 310 |
|
| 311 |
+
await ensureUserIndexLoaded(userId);
|
|
|
|
| 312 |
|
| 313 |
for (const s of d.sessions.values()) {
|
|
|
|
| 314 |
if (!s.history || s.history.length === 0) continue;
|
| 315 |
+
if (ensureUserState(userId).sessionsMeta.has(s.id)) continue;
|
| 316 |
+
const copy = ensureSessionShape(JSON.parse(JSON.stringify(s)));
|
| 317 |
+
await withUserWriteLock(userId, async () => {
|
| 318 |
+
const state = ensureUserState(userId);
|
| 319 |
+
state.loadedSessions.set(copy.id, copy);
|
| 320 |
+
state.sessionsMeta.set(copy.id, buildSessionMeta(copy));
|
| 321 |
+
await saveUserSessionToDisk(userId, copy);
|
| 322 |
+
await saveUserIndex(userId);
|
| 323 |
+
});
|
| 324 |
}
|
| 325 |
},
|
| 326 |
|
| 327 |
+
// USERS
|
| 328 |
_ensureUser(uid) {
|
| 329 |
+
return ensureUserState(uid);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
},
|
| 331 |
+
|
| 332 |
+
async loadUserSessions(userId, _accessToken) {
|
| 333 |
+
const state = await ensureUserIndexLoaded(userId);
|
| 334 |
+
return [...state.sessionsMeta.values()]
|
| 335 |
+
.sort((a, b) => new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime())
|
| 336 |
+
.map((meta) => sessionForList(meta, state.loadedSessions.get(meta.id)));
|
| 337 |
},
|
| 338 |
+
|
| 339 |
+
getUserSessions(uid) {
|
| 340 |
+
const state = userCache.get(uid);
|
| 341 |
+
if (!state) return [];
|
| 342 |
+
return [...state.sessionsMeta.values()].map((meta) => sessionForList(meta, state.loadedSessions.get(meta.id)));
|
| 343 |
+
},
|
| 344 |
+
|
| 345 |
+
getUserSession(uid, id) {
|
| 346 |
+
return userCache.get(uid)?.loadedSessions.get(id) || null;
|
| 347 |
+
},
|
| 348 |
+
|
| 349 |
+
async getUserSessionResolved(uid, id) {
|
| 350 |
+
const state = await ensureUserIndexLoaded(uid);
|
| 351 |
+
if (state.loadedSessions.has(id)) return state.loadedSessions.get(id);
|
| 352 |
+
const meta = state.sessionsMeta.get(id);
|
| 353 |
+
if (!meta) return null;
|
| 354 |
+
|
| 355 |
+
const loaded = await loadUserSessionFromDisk(uid, id);
|
| 356 |
+
if (!loaded) return null;
|
| 357 |
+
|
| 358 |
+
const merged = ensureSessionShape({
|
| 359 |
+
...loaded,
|
| 360 |
+
id,
|
| 361 |
+
name: loaded.name || meta.name,
|
| 362 |
+
created: Number.isFinite(loaded.created) ? loaded.created : meta.created,
|
| 363 |
+
model: loaded.model || meta.model,
|
| 364 |
+
}, id);
|
| 365 |
+
|
| 366 |
+
state.loadedSessions.set(id, merged);
|
| 367 |
+
return merged;
|
| 368 |
+
},
|
| 369 |
+
|
| 370 |
+
async createUserSession(userId, _accessToken) {
|
| 371 |
+
const s = ensureSessionShape({
|
| 372 |
+
id: crypto.randomUUID(),
|
| 373 |
+
name: 'New Chat',
|
| 374 |
+
created: Date.now(),
|
| 375 |
+
history: [],
|
| 376 |
+
model: null,
|
| 377 |
+
});
|
| 378 |
+
|
| 379 |
+
await ensureUserIndexLoaded(userId);
|
| 380 |
+
await withUserWriteLock(userId, async () => {
|
| 381 |
+
const state = ensureUserState(userId);
|
| 382 |
+
state.loadedSessions.set(s.id, s);
|
| 383 |
+
state.sessionsMeta.set(s.id, buildSessionMeta(s));
|
| 384 |
+
await saveUserSessionToDisk(userId, s);
|
| 385 |
+
await saveUserIndex(userId);
|
| 386 |
+
});
|
| 387 |
return s;
|
| 388 |
},
|
| 389 |
+
|
| 390 |
+
async restoreUserSession(userId, _accessToken, session) {
|
| 391 |
+
const restored = ensureSessionShape(JSON.parse(JSON.stringify(session)));
|
| 392 |
+
await ensureUserIndexLoaded(userId);
|
| 393 |
+
|
| 394 |
+
await withUserWriteLock(userId, async () => {
|
| 395 |
+
const state = ensureUserState(userId);
|
| 396 |
+
state.loadedSessions.set(restored.id, restored);
|
| 397 |
+
state.sessionsMeta.set(restored.id, buildSessionMeta(restored, state.sessionsMeta.get(restored.id)));
|
| 398 |
+
await saveUserSessionToDisk(userId, restored);
|
| 399 |
+
await saveUserIndex(userId);
|
| 400 |
+
});
|
| 401 |
+
return restored;
|
| 402 |
},
|
| 403 |
+
|
| 404 |
+
async updateUserSession(userId, _accessToken, sessionId, patch) {
|
| 405 |
+
await ensureUserIndexLoaded(userId);
|
| 406 |
+
const current = await this.getUserSessionResolved(userId, sessionId);
|
| 407 |
+
if (!current) return null;
|
| 408 |
+
|
| 409 |
+
Object.assign(current, patch || {});
|
| 410 |
+
const updated = ensureSessionShape(current, sessionId);
|
| 411 |
+
|
| 412 |
+
await withUserWriteLock(userId, async () => {
|
| 413 |
+
const state = ensureUserState(userId);
|
| 414 |
+
state.loadedSessions.set(sessionId, updated);
|
| 415 |
+
state.sessionsMeta.set(sessionId, buildSessionMeta(updated, state.sessionsMeta.get(sessionId)));
|
| 416 |
+
await saveUserSessionToDisk(userId, updated);
|
| 417 |
+
await saveUserIndex(userId);
|
| 418 |
+
});
|
| 419 |
+
|
| 420 |
+
return updated;
|
| 421 |
},
|
| 422 |
+
|
| 423 |
+
async deleteUserSession(userId, _accessToken, id) {
|
| 424 |
+
await ensureUserIndexLoaded(userId);
|
| 425 |
+
await withUserWriteLock(userId, async () => {
|
| 426 |
+
const state = ensureUserState(userId);
|
| 427 |
+
state.loadedSessions.delete(id);
|
| 428 |
+
state.sessionsMeta.delete(id);
|
| 429 |
+
await deleteUserSessionFromDisk(userId, id);
|
| 430 |
+
await saveUserIndex(userId);
|
| 431 |
});
|
| 432 |
},
|
| 433 |
+
|
| 434 |
+
async deleteAllUserSessions(userId, _accessToken) {
|
| 435 |
+
await ensureUserIndexLoaded(userId);
|
| 436 |
+
const state = ensureUserState(userId);
|
| 437 |
+
const ids = [...state.sessionsMeta.keys()];
|
| 438 |
+
|
| 439 |
+
await withUserWriteLock(userId, async () => {
|
| 440 |
+
for (const id of ids) {
|
| 441 |
+
await deleteUserSessionFromDisk(userId, id);
|
| 442 |
+
}
|
| 443 |
+
state.loadedSessions.clear();
|
| 444 |
+
state.sessionsMeta.clear();
|
| 445 |
+
await saveUserIndex(userId);
|
| 446 |
});
|
| 447 |
+
|
| 448 |
+
return true;
|
| 449 |
+
},
|
| 450 |
+
|
| 451 |
+
markOnline(uid, ws) {
|
| 452 |
+
ensureUserState(uid).online.add(ws);
|
| 453 |
},
|
| 454 |
+
|
| 455 |
+
markOffline(uid, ws) {
|
| 456 |
+
userCache.get(uid)?.online.delete(ws);
|
| 457 |
+
},
|
| 458 |
+
|
| 459 |
+
// SHARE
|
| 460 |
+
async createShareToken(userId, _accessToken, sessionId) {
|
| 461 |
+
const s = await this.getUserSessionResolved(userId, sessionId);
|
| 462 |
+
if (!s) return null;
|
| 463 |
+
|
| 464 |
+
const token = crypto.randomBytes(24).toString('base64url');
|
| 465 |
+
await ensureShareIndexLoaded();
|
| 466 |
+
shareState.index.shares[token] = {
|
| 467 |
+
token,
|
| 468 |
+
owner_id: userId,
|
| 469 |
+
session_snapshot: JSON.parse(JSON.stringify(s)),
|
| 470 |
+
created_at: nowIso(),
|
| 471 |
+
};
|
| 472 |
+
await saveShareIndex();
|
| 473 |
+
return token;
|
| 474 |
+
},
|
| 475 |
+
|
| 476 |
async resolveShareToken(token) {
|
| 477 |
+
await ensureShareIndexLoaded();
|
| 478 |
+
return shareState.index.shares[String(token || '')] || null;
|
|
|
|
|
|
|
| 479 |
},
|
| 480 |
+
|
| 481 |
async importSharedSession(userId, accessToken, token) {
|
| 482 |
+
const shared = await this.resolveShareToken(token);
|
| 483 |
+
if (!shared) return null;
|
| 484 |
+
const snap = ensureSessionShape(shared.session_snapshot);
|
| 485 |
+
const newSession = {
|
| 486 |
+
...snap,
|
| 487 |
+
id: crypto.randomUUID(),
|
| 488 |
+
name: `${snap.name} (shared)`,
|
| 489 |
+
created: Date.now(),
|
| 490 |
+
};
|
| 491 |
+
await this.restoreUserSession(userId, accessToken, newSession);
|
| 492 |
return newSession;
|
| 493 |
},
|
| 494 |
};
|
|
|
|
| 496 |
export const deviceSessionStore = {
|
| 497 |
create(userId, ip, userAgent) {
|
| 498 |
const token = crypto.randomBytes(32).toString('hex');
|
| 499 |
+
devSessions.set(token, {
|
| 500 |
+
token,
|
| 501 |
+
userId,
|
| 502 |
+
ip,
|
| 503 |
+
userAgent,
|
| 504 |
+
createdAt: nowIso(),
|
| 505 |
+
lastSeen: nowIso(),
|
| 506 |
+
active: true,
|
| 507 |
+
});
|
| 508 |
return token;
|
| 509 |
},
|
| 510 |
+
getForUser(uid) {
|
| 511 |
+
return [...devSessions.values()].filter((s) => s.userId === uid && s.active);
|
| 512 |
+
},
|
| 513 |
+
revoke(token) {
|
| 514 |
+
const s = devSessions.get(token);
|
| 515 |
+
if (s) {
|
| 516 |
+
s.active = false;
|
| 517 |
+
return s;
|
| 518 |
+
}
|
| 519 |
+
return null;
|
| 520 |
+
},
|
| 521 |
revokeAllExcept(uid, except) {
|
| 522 |
+
for (const [t, s] of devSessions) {
|
| 523 |
+
if (s.userId === uid && t !== except) s.active = false;
|
| 524 |
+
}
|
| 525 |
},
|
| 526 |
+
validate(token) {
|
| 527 |
+
const s = devSessions.get(token);
|
| 528 |
+
if (!s || !s.active) return null;
|
| 529 |
+
s.lastSeen = nowIso();
|
| 530 |
+
return s;
|
| 531 |
},
|
| 532 |
};
|
server/wsHandler.js
CHANGED
|
@@ -230,7 +230,7 @@ const handlers = {
|
|
| 230 |
'sessions:delete': async (ws, msg, client) => {
|
| 231 |
const owner = getClientOwner(client);
|
| 232 |
const session = client.userId
|
| 233 |
-
? sessionStore.
|
| 234 |
: sessionStore.getTempSession(client.tempId, msg.sessionId);
|
| 235 |
if (!session) return safeSend(ws, { type: 'error', message: 'Session not found' });
|
| 236 |
|
|
@@ -250,7 +250,11 @@ const handlers = {
|
|
| 250 |
const sessions = client.userId
|
| 251 |
? sessionStore.getUserSessions(client.userId)
|
| 252 |
: sessionStore.getTempSessions(client.tempId);
|
| 253 |
-
for (const
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
await chatTrashStore.add(owner, JSON.parse(JSON.stringify(session)));
|
| 255 |
if (client.userId) sessionStore.deleteTempSessionEverywhere(session.id);
|
| 256 |
}
|
|
@@ -268,9 +272,9 @@ const handlers = {
|
|
| 268 |
safeSend(ws, { type: 'sessions:renamed', sessionId: msg.sessionId, name });
|
| 269 |
},
|
| 270 |
|
| 271 |
-
'sessions:get': (ws, msg, client) => {
|
| 272 |
const s = client.userId
|
| 273 |
-
? sessionStore.
|
| 274 |
: sessionStore.getTempSession(client.tempId, msg.sessionId);
|
| 275 |
if (!s) return safeSend(ws, { type: 'error', message: 'Session not found' });
|
| 276 |
safeSend(ws, { type: 'sessions:data', session: ser(s) });
|
|
@@ -325,7 +329,7 @@ const handlers = {
|
|
| 325 |
sessionStore.tempBump(client.tempId);
|
| 326 |
}
|
| 327 |
const session = client.userId
|
| 328 |
-
? sessionStore.
|
| 329 |
: sessionStore.getTempSession(client.tempId, sessionId);
|
| 330 |
if (!session) return safeSend(ws, { type: 'error', message: 'Session not found' });
|
| 331 |
|
|
@@ -466,7 +470,7 @@ const handlers = {
|
|
| 466 |
'chat:editMessage': async (ws, msg, client) => {
|
| 467 |
const { sessionId, messageIndex, newContent } = msg;
|
| 468 |
const session = client.userId
|
| 469 |
-
? sessionStore.
|
| 470 |
: sessionStore.getTempSession(client.tempId, sessionId);
|
| 471 |
if (!session) return safeSend(ws, { type: 'error', message: 'Session not found' });
|
| 472 |
|
|
@@ -527,7 +531,7 @@ const handlers = {
|
|
| 527 |
'chat:selectVersion': async (ws, msg, client) => {
|
| 528 |
const { sessionId, messageIndex, versionIdx } = msg;
|
| 529 |
const session = client.userId
|
| 530 |
-
? sessionStore.
|
| 531 |
: sessionStore.getTempSession(client.tempId, sessionId);
|
| 532 |
if (!session) return;
|
| 533 |
|
|
@@ -563,7 +567,7 @@ const handlers = {
|
|
| 563 |
const { sessionId, messageIndex } = msg;
|
| 564 |
const action = msg.action === 'continue' ? 'continue' : 'regenerate';
|
| 565 |
const session = client.userId
|
| 566 |
-
? sessionStore.
|
| 567 |
: sessionStore.getTempSession(client.tempId, sessionId);
|
| 568 |
if (!session) return safeSend(ws, { type: 'error', message: 'Session not found' });
|
| 569 |
|
|
@@ -861,7 +865,7 @@ async function restoreDeletedSession(client, snapshot) {
|
|
| 861 |
if (!snapshot) return null;
|
| 862 |
const restored = JSON.parse(JSON.stringify(snapshot));
|
| 863 |
const existing = client.userId
|
| 864 |
-
? sessionStore.
|
| 865 |
: sessionStore.getTempSession(client.tempId, restored.id);
|
| 866 |
if (existing) restored.id = crypto.randomUUID();
|
| 867 |
restored.created = restored.created || Date.now();
|
|
|
|
| 230 |
'sessions:delete': async (ws, msg, client) => {
|
| 231 |
const owner = getClientOwner(client);
|
| 232 |
const session = client.userId
|
| 233 |
+
? await sessionStore.getUserSessionResolved(client.userId, msg.sessionId)
|
| 234 |
: sessionStore.getTempSession(client.tempId, msg.sessionId);
|
| 235 |
if (!session) return safeSend(ws, { type: 'error', message: 'Session not found' });
|
| 236 |
|
|
|
|
| 250 |
const sessions = client.userId
|
| 251 |
? sessionStore.getUserSessions(client.userId)
|
| 252 |
: sessionStore.getTempSessions(client.tempId);
|
| 253 |
+
for (const listedSession of sessions) {
|
| 254 |
+
const session = client.userId
|
| 255 |
+
? await sessionStore.getUserSessionResolved(client.userId, listedSession.id)
|
| 256 |
+
: listedSession;
|
| 257 |
+
if (!session) continue;
|
| 258 |
await chatTrashStore.add(owner, JSON.parse(JSON.stringify(session)));
|
| 259 |
if (client.userId) sessionStore.deleteTempSessionEverywhere(session.id);
|
| 260 |
}
|
|
|
|
| 272 |
safeSend(ws, { type: 'sessions:renamed', sessionId: msg.sessionId, name });
|
| 273 |
},
|
| 274 |
|
| 275 |
+
'sessions:get': async (ws, msg, client) => {
|
| 276 |
const s = client.userId
|
| 277 |
+
? await sessionStore.getUserSessionResolved(client.userId, msg.sessionId)
|
| 278 |
: sessionStore.getTempSession(client.tempId, msg.sessionId);
|
| 279 |
if (!s) return safeSend(ws, { type: 'error', message: 'Session not found' });
|
| 280 |
safeSend(ws, { type: 'sessions:data', session: ser(s) });
|
|
|
|
| 329 |
sessionStore.tempBump(client.tempId);
|
| 330 |
}
|
| 331 |
const session = client.userId
|
| 332 |
+
? await sessionStore.getUserSessionResolved(client.userId, sessionId)
|
| 333 |
: sessionStore.getTempSession(client.tempId, sessionId);
|
| 334 |
if (!session) return safeSend(ws, { type: 'error', message: 'Session not found' });
|
| 335 |
|
|
|
|
| 470 |
'chat:editMessage': async (ws, msg, client) => {
|
| 471 |
const { sessionId, messageIndex, newContent } = msg;
|
| 472 |
const session = client.userId
|
| 473 |
+
? await sessionStore.getUserSessionResolved(client.userId, sessionId)
|
| 474 |
: sessionStore.getTempSession(client.tempId, sessionId);
|
| 475 |
if (!session) return safeSend(ws, { type: 'error', message: 'Session not found' });
|
| 476 |
|
|
|
|
| 531 |
'chat:selectVersion': async (ws, msg, client) => {
|
| 532 |
const { sessionId, messageIndex, versionIdx } = msg;
|
| 533 |
const session = client.userId
|
| 534 |
+
? await sessionStore.getUserSessionResolved(client.userId, sessionId)
|
| 535 |
: sessionStore.getTempSession(client.tempId, sessionId);
|
| 536 |
if (!session) return;
|
| 537 |
|
|
|
|
| 567 |
const { sessionId, messageIndex } = msg;
|
| 568 |
const action = msg.action === 'continue' ? 'continue' : 'regenerate';
|
| 569 |
const session = client.userId
|
| 570 |
+
? await sessionStore.getUserSessionResolved(client.userId, sessionId)
|
| 571 |
: sessionStore.getTempSession(client.tempId, sessionId);
|
| 572 |
if (!session) return safeSend(ws, { type: 'error', message: 'Session not found' });
|
| 573 |
|
|
|
|
| 865 |
if (!snapshot) return null;
|
| 866 |
const restored = JSON.parse(JSON.stringify(snapshot));
|
| 867 |
const existing = client.userId
|
| 868 |
+
? await sessionStore.getUserSessionResolved(client.userId, restored.id)
|
| 869 |
: sessionStore.getTempSession(client.tempId, restored.id);
|
| 870 |
if (existing) restored.id = crypto.randomUUID();
|
| 871 |
restored.created = restored.created || Date.now();
|