incognitolm commited on
Commit ·
ad573c0
1
Parent(s): 670ebb9
Account Handling
Browse files- public/js/modals.js +2 -2
- public/oauth-callback.html +2 -14
- server/cryptoUtils.js +61 -0
- server/sessionStore.js +45 -5
- server/wsHandler.js +5 -1
public/js/modals.js
CHANGED
|
@@ -126,8 +126,8 @@ export function openAuthModal(initialTab = 'signin') {
|
|
| 126 |
}
|
| 127 |
});
|
| 128 |
|
| 129 |
-
b.querySelector('#github-btn').addEventListener('click', () => loginWithOAuth('github'));
|
| 130 |
-
b.querySelector('#google-btn').addEventListener('click', () => loginWithOAuth('google'));
|
| 131 |
b.querySelector('#forgot-pw').addEventListener('click', () => openForgotPasswordModal());
|
| 132 |
|
| 133 |
// Enter key
|
|
|
|
| 126 |
}
|
| 127 |
});
|
| 128 |
|
| 129 |
+
b.querySelector('#github-btn').addEventListener('click', () => { loginWithOAuth('github'); closeModal(); });
|
| 130 |
+
b.querySelector('#google-btn').addEventListener('click', () => { loginWithOAuth('google'); closeModal(); });
|
| 131 |
b.querySelector('#forgot-pw').addEventListener('click', () => openForgotPasswordModal());
|
| 132 |
|
| 133 |
// Enter key
|
public/oauth-callback.html
CHANGED
|
@@ -58,21 +58,9 @@
|
|
| 58 |
}
|
| 59 |
|
| 60 |
msgEl.textContent = 'Sign-in complete! Closing…';
|
| 61 |
-
// Give localStorage a moment to propagate, then close.
|
| 62 |
setTimeout(() => {
|
| 63 |
-
|
| 64 |
-
if (window.opener !== null || window.history.length <= 1) {
|
| 65 |
-
window.close();
|
| 66 |
-
}
|
| 67 |
-
// If close() didn't work (e.g. tab opened directly), redirect home.
|
| 68 |
-
setTimeout(() => {
|
| 69 |
-
const p = new URLSearchParams({
|
| 70 |
-
oauth: '1',
|
| 71 |
-
t: tokens.access_token,
|
| 72 |
-
r: tokens.refresh_token || '',
|
| 73 |
-
});
|
| 74 |
-
location.replace('/?' + p.toString());
|
| 75 |
-
}, 800);
|
| 76 |
}, 300);
|
| 77 |
} else {
|
| 78 |
msgEl.textContent = 'No token received. You can close this window.';
|
|
|
|
| 58 |
}
|
| 59 |
|
| 60 |
msgEl.textContent = 'Sign-in complete! Closing…';
|
| 61 |
+
// Give localStorage a moment to propagate, then try to close the popup.
|
| 62 |
setTimeout(() => {
|
| 63 |
+
try { window.close(); } catch (_) { /* ignore */ }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
}, 300);
|
| 65 |
} else {
|
| 66 |
msgEl.textContent = 'No token received. You can close this window.';
|
server/cryptoUtils.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import crypto from 'crypto';
|
| 2 |
+
import fs from 'fs/promises';
|
| 3 |
+
import path from 'path';
|
| 4 |
+
|
| 5 |
+
const ALGORITHM = 'aes-256-gcm';
|
| 6 |
+
const KEY_LENGTH = 32; // 256 bits
|
| 7 |
+
const IV_LENGTH = 16; // 128 bits for GCM
|
| 8 |
+
const AUTH_TAG_LENGTH = 16; // 128 bits
|
| 9 |
+
|
| 10 |
+
// Derive key from environment variable
|
| 11 |
+
function getKey() {
|
| 12 |
+
const keyEnv = process.env.DATA_ENCRYPTION_KEY;
|
| 13 |
+
if (!keyEnv) throw new Error('DATA_ENCRYPTION_KEY environment variable not set');
|
| 14 |
+
// Use SHA-256 to derive a 32-byte key from the env var
|
| 15 |
+
return crypto.createHash('sha256').update(keyEnv).digest();
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export function encryptJson(data) {
|
| 19 |
+
const key = getKey();
|
| 20 |
+
const iv = crypto.randomBytes(IV_LENGTH);
|
| 21 |
+
const cipher = crypto.createCipher(ALGORITHM, key);
|
| 22 |
+
cipher.setAAD(Buffer.from('')); // Optional AAD
|
| 23 |
+
|
| 24 |
+
let encrypted = cipher.update(JSON.stringify(data), 'utf8', 'hex');
|
| 25 |
+
encrypted += cipher.final('hex');
|
| 26 |
+
|
| 27 |
+
const authTag = cipher.getAuthTag();
|
| 28 |
+
return {
|
| 29 |
+
iv: iv.toString('hex'),
|
| 30 |
+
encrypted,
|
| 31 |
+
authTag: authTag.toString('hex'),
|
| 32 |
+
};
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export function decryptJson(encryptedData) {
|
| 36 |
+
const key = getKey();
|
| 37 |
+
const { iv, encrypted, authTag } = encryptedData;
|
| 38 |
+
const decipher = crypto.createDecipher(ALGORITHM, key);
|
| 39 |
+
decipher.setAuthTag(Buffer.from(authTag, 'hex'));
|
| 40 |
+
decipher.setAAD(Buffer.from('')); // Match AAD
|
| 41 |
+
|
| 42 |
+
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
| 43 |
+
decrypted += decipher.final('utf8');
|
| 44 |
+
return JSON.parse(decrypted);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
export async function saveEncryptedJson(filePath, data) {
|
| 48 |
+
const encrypted = encryptJson(data);
|
| 49 |
+
await fs.writeFile(filePath, JSON.stringify(encrypted));
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
export async function loadEncryptedJson(filePath) {
|
| 53 |
+
try {
|
| 54 |
+
const content = await fs.readFile(filePath, 'utf8');
|
| 55 |
+
const encrypted = JSON.parse(content);
|
| 56 |
+
return decryptJson(encrypted);
|
| 57 |
+
} catch (err) {
|
| 58 |
+
if (err.code === 'ENOENT') return null; // File not found
|
| 59 |
+
throw err;
|
| 60 |
+
}
|
| 61 |
+
}
|
server/sessionStore.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
| 2 |
// Device sessions live in memory only (restart clears them).
|
| 3 |
import { createClient } from '@supabase/supabase-js';
|
| 4 |
import crypto from 'crypto';
|
|
|
|
|
|
|
| 5 |
|
| 6 |
let _SUPABASE_URL, _SUPABASE_ANON_KEY;
|
| 7 |
export function initStoreConfig(url, key) { _SUPABASE_URL = url; _SUPABASE_ANON_KEY = key; }
|
|
@@ -14,11 +16,45 @@ const userCache = new Map(); // userId -> { sessions: Map, online: Set }
|
|
| 14 |
const tempStore = new Map(); // tempId -> TempData
|
| 15 |
const devSessions = new Map(); // token -> DeviceSession
|
| 16 |
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
const now = Date.now();
|
| 19 |
for (const [id, d] of tempStore)
|
| 20 |
if (now - d.created > TEMP_TTL_MS || now - d.lastActive > TEMP_INACTIVITY)
|
| 21 |
tempStore.delete(id);
|
|
|
|
|
|
|
| 22 |
}, 30 * 60 * 1000);
|
| 23 |
|
| 24 |
function userClient(accessToken) {
|
|
@@ -42,15 +78,19 @@ export const sessionStore = {
|
|
| 42 |
createTempSession(t) {
|
| 43 |
const d = this.initTemp(t);
|
| 44 |
const s = { id: crypto.randomUUID(), name: 'New Chat', created: Date.now(), history: [] };
|
| 45 |
-
d.sessions.set(s.id, s); d.lastActive = Date.now();
|
|
|
|
|
|
|
| 46 |
},
|
| 47 |
updateTempSession(t, id, patch) {
|
| 48 |
const d = tempStore.get(t); if (!d) return null;
|
| 49 |
const s = d.sessions.get(id); if (!s) return null;
|
| 50 |
-
Object.assign(s, patch); d.lastActive = Date.now();
|
|
|
|
|
|
|
| 51 |
},
|
| 52 |
-
deleteTempSession(t, id) { tempStore.get(t)?.sessions.delete(id); },
|
| 53 |
-
deleteTempAll(t) { tempStore.get(t)?.sessions.clear(); },
|
| 54 |
|
| 55 |
/**
|
| 56 |
* Copy temp sessions into the user's account on login.
|
|
|
|
| 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; }
|
|
|
|
| 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 |
+
|
| 35 |
+
async function saveTempStore() {
|
| 36 |
+
const data = {};
|
| 37 |
+
for (const [id, d] of tempStore) {
|
| 38 |
+
data[id] = {
|
| 39 |
+
sessions: Object.fromEntries(d.sessions),
|
| 40 |
+
msgCount: d.msgCount,
|
| 41 |
+
created: d.created,
|
| 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) {
|
|
|
|
| 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 |
+
deleteTempSession(t, id) { tempStore.get(t)?.sessions.delete(id); saveTempStore().catch(err => console.error('Failed to save temp store:', err)); },
|
| 93 |
+
deleteTempAll(t) { tempStore.get(t)?.sessions.clear(); saveTempStore().catch(err => console.error('Failed to save temp store:', err)); },
|
| 94 |
|
| 95 |
/**
|
| 96 |
* Copy temp sessions into the user's account on login.
|
server/wsHandler.js
CHANGED
|
@@ -56,7 +56,9 @@ const handlers = {
|
|
| 56 |
client.deviceToken = deviceSessionStore.create(user.id, client.ip, client.userAgent);
|
| 57 |
sessionStore.markOnline(user.id, ws);
|
| 58 |
|
| 59 |
-
|
|
|
|
|
|
|
| 60 |
await sessionStore.transferTempToUser(tId, user.id, accessToken);
|
| 61 |
|
| 62 |
const [sessions, settings, profile] = await Promise.all([
|
|
@@ -79,7 +81,9 @@ const handlers = {
|
|
| 79 |
},
|
| 80 |
|
| 81 |
'auth:guest': (ws, msg, client) => {
|
|
|
|
| 82 |
const t = msg.tempId || client.tempId;
|
|
|
|
| 83 |
sessionStore.initTemp(t);
|
| 84 |
safeSend(ws, { type: 'auth:guestOk', tempId: t, sessions: sessionStore.getTempSessions(t).map(ser) });
|
| 85 |
},
|
|
|
|
| 56 |
client.deviceToken = deviceSessionStore.create(user.id, client.ip, client.userAgent);
|
| 57 |
sessionStore.markOnline(user.id, ws);
|
| 58 |
|
| 59 |
+
// If the client provided a persistent tempId, use and save it on the ws client
|
| 60 |
+
if (clientTempId) client.tempId = clientTempId;
|
| 61 |
+
const tId = client.tempId;
|
| 62 |
await sessionStore.transferTempToUser(tId, user.id, accessToken);
|
| 63 |
|
| 64 |
const [sessions, settings, profile] = await Promise.all([
|
|
|
|
| 81 |
},
|
| 82 |
|
| 83 |
'auth:guest': (ws, msg, client) => {
|
| 84 |
+
// Honor a client-supplied persistent tempId and persist it on the ws client
|
| 85 |
const t = msg.tempId || client.tempId;
|
| 86 |
+
client.tempId = t;
|
| 87 |
sessionStore.initTemp(t);
|
| 88 |
safeSend(ws, { type: 'auth:guestOk', tempId: t, sessions: sessionStore.getTempSessions(t).map(ser) });
|
| 89 |
},
|