| import crypto from 'crypto'; |
| import fs from 'fs/promises'; |
| import path from 'path'; |
| import { loadEncryptedJson, saveEncryptedJson } from './cryptoUtils.js'; |
|
|
| |
| |
| |
|
|
| 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(); |
| const tempStore = new Map(); |
| const devSessions = new Map(); |
| const userWriteLocks = new Map(); |
|
|
| 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 = { |
| |
| 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); |
| }); |
| } |
| }, |
|
|
| |
| _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); |
| }, |
|
|
| |
| 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; |
| }, |
| }; |
|
|