sharktide commited on
Commit
82f6446
·
1 Parent(s): e0fe1d6

Store data in bucket

Browse files
Files changed (4) hide show
  1. server/index.js +479 -0
  2. server/mediaStore.js +15 -0
  3. server/sessionStore.js +439 -169
  4. 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 { saveEncryptedJson, loadEncryptedJson } from './cryptoUtils.js';
6
  import path from 'path';
 
7
 
8
- let _SUPABASE_URL, _SUPABASE_ANON_KEY;
9
- export function initStoreConfig(url, key) { _SUPABASE_URL = url; _SUPABASE_ANON_KEY = key; }
 
10
 
11
- const TEMP_TTL_MS = 24 * 60 * 60 * 1000;
 
 
 
 
 
 
 
12
  const TEMP_INACTIVITY = 12 * 60 * 60 * 1000;
13
- const TEMP_MSG_LIMIT = 10;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
- const userCache = new Map(); // userId -> { sessions: Map, online: Set }
16
- const tempStore = new Map(); // tempId -> TempData
17
- const devSessions = new Map(); // token -> DeviceSession
18
 
19
- const TEMP_STORE_FILE = '/data/temp_sessions.json';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  async function loadTempStore() {
22
- const data = await loadEncryptedJson(TEMP_STORE_FILE);
23
- if (data) {
24
- for (const [id, d] of Object.entries(data)) {
25
- tempStore.set(id, {
26
- sessions: new Map(Object.entries(d.sessions || {})),
27
- msgCount: d.msgCount || 0,
28
- created: d.created || Date.now(),
29
- lastActive: d.lastActive || Date.now(),
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
- // Load temp store on init
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
- // Save after cleanup
57
- await saveTempStore().catch(err => console.error('Failed to save temp store:', err));
 
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
- // ── TEMP ────────────────────────────────────────────────────────────────
69
  initTemp(t) {
70
- if (!tempStore.has(t))
71
- tempStore.set(t, { sessions: new Map(), msgCount: 0, created: Date.now(), lastActive: Date.now() });
 
 
 
 
 
 
72
  return tempStore.get(t);
73
  },
74
- tempCanSend(t) { const d = tempStore.get(t); return d ? d.msgCount < TEMP_MSG_LIMIT : false; },
75
- tempBump(t) { const d = tempStore.get(t); if (d) { d.msgCount++; d.lastActive = Date.now(); } },
76
- getTempSessions(t) { return [...(tempStore.get(t)?.sessions.values() || [])]; },
77
- getTempSession(t, id) { return tempStore.get(t)?.sessions.get(id) || null; },
 
 
 
 
 
 
 
 
 
 
 
 
 
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); d.lastActive = Date.now();
82
- saveTempStore().catch(err => console.error('Failed to save temp store:', err));
 
83
  return s;
84
  },
85
  updateTempSession(t, id, patch) {
86
- const d = tempStore.get(t); if (!d) return null;
87
- const s = d.sessions.get(id); if (!s) return null;
88
- Object.assign(s, patch); d.lastActive = Date.now();
89
- saveTempStore().catch(err => console.error('Failed to save temp store:', err));
 
 
 
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) { tempStore.get(t)?.sessions.delete(id); saveTempStore().catch(err => console.error('Failed to save temp store:', err)); },
101
- deleteTempAll(t) { tempStore.get(t)?.sessions.clear(); saveTempStore().catch(err => console.error('Failed to save temp store:', err)); },
 
 
 
 
 
 
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
- const uc = userClient(accessToken);
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
- // Skip if the user already has a session with the same id
131
- if (user.sessions.has(s.id)) continue;
132
-
133
- // Deep-clone so mutations to the user copy don't affect temp copy
134
- const copy = JSON.parse(JSON.stringify(s));
135
- user.sessions.set(copy.id, copy);
136
- await this._persist(uc, userId, copy).catch(err =>
137
- console.error('transferTempToUser persist error:', err.message));
138
  }
139
  },
140
 
141
- // ── USERS ────────────────────────────────────────────────────────────────
142
  _ensureUser(uid) {
143
- if (!userCache.has(uid)) userCache.set(uid, { sessions: new Map(), online: new Set() });
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
- async restoreUserSession(userId, accessToken, session) {
166
- const restored = JSON.parse(JSON.stringify(session));
167
- this._ensureUser(userId).sessions.set(restored.id, restored);
168
- await this._persist(userClient(accessToken), userId, restored).catch(() => {});
169
- return restored;
 
170
  },
171
- async updateUserSession(userId, accessToken, sessionId, patch) {
172
- const user = userCache.get(userId); if (!user) { console.error("No user for " + userId); return null; }
173
- const s = user.sessions.get(sessionId); if (!s) { console.error ("No session found for " + sessionId); return null; }
174
- Object.assign(s, patch);
175
- await this._persist(userClient(accessToken), userId, s).catch(() => {});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  return s;
177
  },
178
- async deleteUserSession(userId, accessToken, id) {
179
- try {
180
- userCache.get(userId)?.sessions.delete(id);
181
- const { error } = await userClient(accessToken)
182
- .from('web_sessions')
183
- .delete()
184
- .eq('id', id)
185
- .eq('user_id', userId);
186
- if (error) console.error('Supabase delete error:', error.message);
187
- } catch (ex) {
188
- console.error('Unexpected deleteUserSession error:', ex);
189
- }
 
190
  },
191
- async deleteAllUserSessions(userId, accessToken) {
192
- const u = userCache.get(userId);
193
- if (u) {
194
- u.sessions.clear();
195
- } else {
196
- console.error('No user for ' + userId);
197
- return null;
198
- }
199
- try {
200
- const { error } = await userClient(accessToken)
201
- .from('web_sessions')
202
- .delete()
203
- .eq('user_id', userId);
204
- if (error) console.error('Supabase bulk delete error:', error.message);
205
- } catch (ex) {
206
- console.error('Unexpected deleteAllUserSessions error:', ex);
207
- }
 
208
  },
209
- async _persist(uc, userId, s) {
210
- await uc.from('web_sessions').upsert({
211
- id: s.id, user_id: userId, name: s.name, history: s.history || [],
212
- model: s.model || null, updated_at: new Date().toISOString(),
213
- created_at: new Date(s.created).toISOString(),
 
 
 
 
214
  });
215
  },
216
- markOnline(uid, ws) { this._ensureUser(uid).online.add(ws); },
217
- markOffline(uid, ws) { userCache.get(uid)?.online.delete(ws); },
218
- // ── SHARE ────────────────────────────────────────────────────────────────
219
- async createShareToken(userId, accessToken, sessionId) {
220
- const s = this.getUserSession(userId, sessionId); if (!s) return null;
221
- const token = crypto.randomBytes(24).toString('base64url');
222
- const uc = userClient(accessToken);
223
- const { error } = await uc.from('shared_sessions').insert({
224
- token, owner_id: userId, session_snapshot: s, created_at: new Date().toISOString(),
 
 
 
 
225
  });
226
- return error ? null : token;
 
 
 
 
 
227
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  async resolveShareToken(token) {
229
- // shared_sessions SELECT is public via RLS
230
- const uc = createClient(_SUPABASE_URL, _SUPABASE_ANON_KEY, { auth: { persistSession: false } });
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); if (!shared) return null;
236
- const snap = shared.session_snapshot;
237
- const newSession = { ...snap, id: crypto.randomUUID(),
238
- name: `${snap.name} (shared)`, created: Date.now() };
239
- const uc = userClient(accessToken);
240
- this._ensureUser(userId).sessions.set(newSession.id, newSession);
241
- await this._persist(uc, userId, newSession).catch(() => {});
 
 
 
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, { token, userId, ip, userAgent,
250
- createdAt: new Date().toISOString(), lastSeen: new Date().toISOString(), active: true });
 
 
 
 
 
 
 
251
  return token;
252
  },
253
- getForUser(uid) { return [...devSessions.values()].filter(s => s.userId === uid && s.active); },
254
- revoke(token) { const s = devSessions.get(token); if (s) { s.active = false; return s; } return null; },
 
 
 
 
 
 
 
 
 
255
  revokeAllExcept(uid, except) {
256
- for (const [t, s] of devSessions) if (s.userId === uid && t !== except) s.active = false;
 
 
257
  },
258
- validate(token) {
259
- const s = devSessions.get(token); if (!s || !s.active) return null;
260
- s.lastSeen = new Date().toISOString(); return 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.getUserSession(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,7 +250,11 @@ const handlers = {
250
  const sessions = client.userId
251
  ? sessionStore.getUserSessions(client.userId)
252
  : sessionStore.getTempSessions(client.tempId);
253
- for (const session of sessions) {
 
 
 
 
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.getUserSession(client.userId, msg.sessionId)
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.getUserSession(client.userId, sessionId)
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.getUserSession(client.userId, sessionId)
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.getUserSession(client.userId, sessionId)
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.getUserSession(client.userId, sessionId)
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.getUserSession(client.userId, restored.id)
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();