import crypto from 'crypto'; import fs from 'fs/promises'; import path from 'path'; import { loadEncryptedJson, saveEncryptedJson } from './cryptoUtils.js'; // Local encrypted chat storage. // Chats are stored per user in /data/chat/users//sessions/.json (encrypted), // with a lightweight encrypted index for fast listing. let _SUPABASE_URL; let _SUPABASE_ANON_KEY; export function initStoreConfig(url, key) { _SUPABASE_URL = url; _SUPABASE_ANON_KEY = key; } const TEMP_TTL_MS = 24 * 60 * 60 * 1000; const TEMP_INACTIVITY = 12 * 60 * 60 * 1000; const TEMP_MSG_LIMIT = 10; const DATA_ROOT = '/data/chat'; const USERS_ROOT = path.join(DATA_ROOT, 'users'); const SHARES_FILE = path.join(DATA_ROOT, 'shares', 'index.json'); const TEMP_STORE_FILE = path.join(DATA_ROOT, 'temp_sessions.json'); const userCache = new Map(); // userId -> UserState const tempStore = new Map(); // tempId -> TempData const devSessions = new Map(); // token -> DeviceSession const userWriteLocks = new Map(); // userId -> Promise const shareState = { loaded: false, index: { shares: {}, }, }; function nowIso() { return new Date().toISOString(); } function safeFileId(value, fallback = 'unknown') { const normalized = String(value || '').trim(); if (!normalized) return fallback; return normalized.replace(/[^a-zA-Z0-9_.-]+/g, '_').slice(0, 160) || fallback; } function ensureSessionShape(raw, fallbackId = null) { const created = Number.isFinite(raw?.created) ? raw.created : Date.now(); return { id: raw?.id || fallbackId || crypto.randomUUID(), name: String(raw?.name || 'New Chat'), created, history: Array.isArray(raw?.history) ? raw.history : [], model: raw?.model || null, updatedAt: typeof raw?.updatedAt === 'string' && raw.updatedAt.trim() ? raw.updatedAt : nowIso(), }; } function buildSessionMeta(session, existingMeta = null) { const created = Number.isFinite(session?.created) ? session.created : (Number.isFinite(existingMeta?.created) ? existingMeta.created : Date.now()); return { id: session.id, name: String(session.name || existingMeta?.name || 'New Chat'), created, model: session.model || existingMeta?.model || null, updatedAt: nowIso(), }; } function ensureUserState(userId) { if (!userCache.has(userId)) { userCache.set(userId, { indexLoaded: false, sessionsMeta: new Map(), loadedSessions: new Map(), online: new Set(), }); } return userCache.get(userId); } function userDir(userId) { return path.join(USERS_ROOT, safeFileId(userId)); } function userIndexFile(userId) { return path.join(userDir(userId), 'index.json'); } function userSessionFile(userId, sessionId) { return path.join(userDir(userId), 'sessions', `${safeFileId(sessionId)}.json`); } function userIndexAad(userId) { return `chat:user:${userId}:index`; } function userSessionAad(userId, sessionId) { return `chat:user:${userId}:session:${sessionId}`; } function toSerializableSessionMetaMap(map) { const sessions = {}; for (const [id, meta] of map.entries()) { sessions[id] = { id, name: String(meta?.name || 'New Chat'), created: Number.isFinite(meta?.created) ? meta.created : Date.now(), model: meta?.model || null, updatedAt: meta?.updatedAt || nowIso(), }; } return sessions; } async function ensureUserIndexLoaded(userId) { const state = ensureUserState(userId); if (state.indexLoaded) return state; const stored = await loadEncryptedJson(userIndexFile(userId), userIndexAad(userId)); state.sessionsMeta.clear(); const sessions = stored?.sessions || {}; for (const [id, meta] of Object.entries(sessions)) { state.sessionsMeta.set(id, { id, name: String(meta?.name || 'New Chat'), created: Number.isFinite(meta?.created) ? meta.created : Date.now(), model: meta?.model || null, updatedAt: meta?.updatedAt || nowIso(), }); } state.indexLoaded = true; return state; } async function saveUserIndex(userId) { const state = ensureUserState(userId); const payload = { sessions: toSerializableSessionMetaMap(state.sessionsMeta), }; await saveEncryptedJson(userIndexFile(userId), payload, userIndexAad(userId)); } async function loadUserSessionFromDisk(userId, sessionId) { const raw = await loadEncryptedJson(userSessionFile(userId, sessionId), userSessionAad(userId, sessionId)); if (!raw) return null; return ensureSessionShape(raw, sessionId); } async function saveUserSessionToDisk(userId, session) { const shaped = ensureSessionShape(session); await saveEncryptedJson(userSessionFile(userId, shaped.id), shaped, userSessionAad(userId, shaped.id)); } async function deleteUserSessionFromDisk(userId, sessionId) { await fs.rm(userSessionFile(userId, sessionId), { force: true }).catch(() => {}); } async function withUserWriteLock(userId, fn) { const prior = userWriteLocks.get(userId) || Promise.resolve(); const next = prior.catch(() => {}).then(fn); userWriteLocks.set(userId, next.finally(() => { if (userWriteLocks.get(userId) === next) userWriteLocks.delete(userId); })); return next; } function sessionForList(meta, loaded) { const source = loaded || meta; return { id: source.id, name: source.name || 'New Chat', created: Number.isFinite(source.created) ? source.created : Date.now(), history: loaded?.history || [], model: source.model || null, updatedAt: source.updatedAt || null, }; } async function ensureShareIndexLoaded() { if (shareState.loaded) return; const stored = await loadEncryptedJson(SHARES_FILE, 'chat:shares:index'); shareState.index = { shares: stored?.shares || {}, }; shareState.loaded = true; } async function saveShareIndex() { await saveEncryptedJson(SHARES_FILE, shareState.index, 'chat:shares:index'); } async function loadTempStore() { const data = await loadEncryptedJson(TEMP_STORE_FILE, 'chat:temp:index'); if (!data) return; for (const [id, d] of Object.entries(data)) { tempStore.set(id, { sessions: new Map(Object.entries(d.sessions || {})), msgCount: d.msgCount || 0, created: d.created || Date.now(), lastActive: d.lastActive || Date.now(), }); } } async function saveTempStore() { const data = {}; for (const [id, d] of tempStore) { data[id] = { sessions: Object.fromEntries(d.sessions), msgCount: d.msgCount, created: d.created, lastActive: d.lastActive, }; } await saveEncryptedJson(TEMP_STORE_FILE, data, 'chat:temp:index'); } loadTempStore().catch((err) => console.error('Failed to load temp store:', err)); setInterval(async () => { const now = Date.now(); for (const [id, d] of tempStore) { if (now - d.created > TEMP_TTL_MS || now - d.lastActive > TEMP_INACTIVITY) { tempStore.delete(id); } } await saveTempStore().catch((err) => console.error('Failed to save temp store:', err)); }, 30 * 60 * 1000); export const sessionStore = { // TEMP initTemp(t) { if (!tempStore.has(t)) { tempStore.set(t, { sessions: new Map(), msgCount: 0, created: Date.now(), lastActive: Date.now(), }); } return tempStore.get(t); }, tempCanSend(t) { const d = tempStore.get(t); return d ? d.msgCount < TEMP_MSG_LIMIT : false; }, tempBump(t) { const d = tempStore.get(t); if (d) { d.msgCount++; d.lastActive = Date.now(); } }, getTempSessions(t) { return [...(tempStore.get(t)?.sessions.values() || [])]; }, getTempSession(t, id) { return tempStore.get(t)?.sessions.get(id) || null; }, createTempSession(t) { const d = this.initTemp(t); const s = { id: crypto.randomUUID(), name: 'New Chat', created: Date.now(), history: [] }; d.sessions.set(s.id, s); d.lastActive = Date.now(); saveTempStore().catch((err) => console.error('Failed to save temp store:', err)); return s; }, updateTempSession(t, id, patch) { const d = tempStore.get(t); if (!d) return null; const s = d.sessions.get(id); if (!s) return null; Object.assign(s, patch); d.lastActive = Date.now(); saveTempStore().catch((err) => console.error('Failed to save temp store:', err)); return s; }, restoreTempSession(t, session) { const d = this.initTemp(t); const restored = JSON.parse(JSON.stringify(session)); d.sessions.set(restored.id, restored); d.lastActive = Date.now(); saveTempStore().catch((err) => console.error('Failed to save temp store:', err)); return restored; }, deleteTempSession(t, id) { tempStore.get(t)?.sessions.delete(id); saveTempStore().catch((err) => console.error('Failed to save temp store:', err)); }, deleteTempAll(t) { tempStore.get(t)?.sessions.clear(); saveTempStore().catch((err) => console.error('Failed to save temp store:', err)); }, deleteTempSessionEverywhere(id) { let changed = false; for (const temp of tempStore.values()) { if (temp.sessions.delete(id)) changed = true; } if (changed) saveTempStore().catch((err) => console.error('Failed to save temp store:', err)); return changed; }, async transferTempToUser(tempId, userId, _accessToken) { const d = tempStore.get(tempId); if (!d || !d.sessions.size) return; await ensureUserIndexLoaded(userId); for (const s of d.sessions.values()) { if (!s.history || s.history.length === 0) continue; if (ensureUserState(userId).sessionsMeta.has(s.id)) continue; const copy = ensureSessionShape(JSON.parse(JSON.stringify(s))); await withUserWriteLock(userId, async () => { const state = ensureUserState(userId); state.loadedSessions.set(copy.id, copy); state.sessionsMeta.set(copy.id, buildSessionMeta(copy)); await saveUserSessionToDisk(userId, copy); await saveUserIndex(userId); }); } }, // USERS _ensureUser(uid) { return ensureUserState(uid); }, async loadUserSessions(userId, _accessToken) { const state = await ensureUserIndexLoaded(userId); return [...state.sessionsMeta.values()] .sort((a, b) => new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime()) .map((meta) => sessionForList(meta, state.loadedSessions.get(meta.id))); }, getUserSessions(uid) { const state = userCache.get(uid); if (!state) return []; return [...state.sessionsMeta.values()].map((meta) => sessionForList(meta, state.loadedSessions.get(meta.id))); }, getUserSession(uid, id) { return userCache.get(uid)?.loadedSessions.get(id) || null; }, async getUserSessionResolved(uid, id) { const state = await ensureUserIndexLoaded(uid); if (state.loadedSessions.has(id)) return state.loadedSessions.get(id); const meta = state.sessionsMeta.get(id); if (!meta) return null; const loaded = await loadUserSessionFromDisk(uid, id); if (!loaded) return null; const merged = ensureSessionShape({ ...loaded, id, name: loaded.name || meta.name, created: Number.isFinite(loaded.created) ? loaded.created : meta.created, model: loaded.model || meta.model, }, id); state.loadedSessions.set(id, merged); return merged; }, async createUserSession(userId, _accessToken) { const s = ensureSessionShape({ id: crypto.randomUUID(), name: 'New Chat', created: Date.now(), history: [], model: null, }); await ensureUserIndexLoaded(userId); await withUserWriteLock(userId, async () => { const state = ensureUserState(userId); state.loadedSessions.set(s.id, s); state.sessionsMeta.set(s.id, buildSessionMeta(s)); await saveUserSessionToDisk(userId, s); await saveUserIndex(userId); }); return s; }, async restoreUserSession(userId, _accessToken, session) { const restored = ensureSessionShape(JSON.parse(JSON.stringify(session))); await ensureUserIndexLoaded(userId); await withUserWriteLock(userId, async () => { const state = ensureUserState(userId); state.loadedSessions.set(restored.id, restored); state.sessionsMeta.set(restored.id, buildSessionMeta(restored, state.sessionsMeta.get(restored.id))); await saveUserSessionToDisk(userId, restored); await saveUserIndex(userId); }); return restored; }, async updateUserSession(userId, _accessToken, sessionId, patch) { await ensureUserIndexLoaded(userId); const current = await this.getUserSessionResolved(userId, sessionId); if (!current) return null; Object.assign(current, patch || {}); const updated = ensureSessionShape(current, sessionId); await withUserWriteLock(userId, async () => { const state = ensureUserState(userId); state.loadedSessions.set(sessionId, updated); state.sessionsMeta.set(sessionId, buildSessionMeta(updated, state.sessionsMeta.get(sessionId))); await saveUserSessionToDisk(userId, updated); await saveUserIndex(userId); }); return updated; }, async deleteUserSession(userId, _accessToken, id) { await ensureUserIndexLoaded(userId); await withUserWriteLock(userId, async () => { const state = ensureUserState(userId); state.loadedSessions.delete(id); state.sessionsMeta.delete(id); await deleteUserSessionFromDisk(userId, id); await saveUserIndex(userId); }); }, async deleteAllUserSessions(userId, _accessToken) { await ensureUserIndexLoaded(userId); const state = ensureUserState(userId); const ids = [...state.sessionsMeta.keys()]; await withUserWriteLock(userId, async () => { for (const id of ids) { await deleteUserSessionFromDisk(userId, id); } state.loadedSessions.clear(); state.sessionsMeta.clear(); await saveUserIndex(userId); }); return true; }, markOnline(uid, ws) { ensureUserState(uid).online.add(ws); }, markOffline(uid, ws) { userCache.get(uid)?.online.delete(ws); }, // SHARE async createShareToken(userId, _accessToken, sessionId) { const s = await this.getUserSessionResolved(userId, sessionId); if (!s) return null; const token = crypto.randomBytes(24).toString('base64url'); await ensureShareIndexLoaded(); shareState.index.shares[token] = { token, owner_id: userId, session_snapshot: JSON.parse(JSON.stringify(s)), created_at: nowIso(), }; await saveShareIndex(); return token; }, async resolveShareToken(token) { await ensureShareIndexLoaded(); return shareState.index.shares[String(token || '')] || null; }, async importSharedSession(userId, accessToken, token) { const shared = await this.resolveShareToken(token); if (!shared) return null; const snap = ensureSessionShape(shared.session_snapshot); const newSession = { ...snap, id: crypto.randomUUID(), name: `${snap.name} (shared)`, created: Date.now(), }; await this.restoreUserSession(userId, accessToken, newSession); return newSession; }, }; export const deviceSessionStore = { create(userId, ip, userAgent) { const token = crypto.randomBytes(32).toString('hex'); devSessions.set(token, { token, userId, ip, userAgent, createdAt: nowIso(), lastSeen: nowIso(), active: true, }); return token; }, getForUser(uid) { return [...devSessions.values()].filter((s) => s.userId === uid && s.active); }, revoke(token) { const s = devSessions.get(token); if (s) { s.active = false; return s; } return null; }, revokeAllExcept(uid, except) { for (const [t, s] of devSessions) { if (s.userId === uid && t !== except) s.active = false; } }, validate(token) { const s = devSessions.get(token); if (!s || !s.active) return null; s.lastSeen = nowIso(); return s; }, };