firestore + full end-to-end encryption

This commit is contained in:
samtiz 2025-07-09 21:17:16 +09:00
parent bf344268e7
commit c497234230
18 changed files with 363 additions and 185 deletions

51
PLAN.md
View File

@ -1,51 +0,0 @@
# Project Plan: Firebase Integration & Encryption
This document outlines the plan to integrate Firebase Firestore as a remote database for logged-in users and implement end-to-end encryption for user data.
## Phase 1: `encryptionService` and Secure Key Management
The goal of this phase is to create a centralized service for data encryption and decryption, with secure management of encryption keys.
1. **Install `keytar`**: Add the `keytar` package to the project to securely store encryption keys in the OS keychain.
2. **Create `encryptionService.js`**:
- Location: `src/common/services/encryptionService.js`
- Implement `encrypt(text)` and `decrypt(encrypted)` functions using Node.js `crypto` with `AES-256-GCM`.
3. **Implement Key Management**:
- Create an `initializeKey(userId)` function within the service.
- This function will first attempt to retrieve the encryption key from `keytar`.
- If `keytar` fails or no key is found, it will generate a secure, session-only key in memory as a fallback. It will **not** save the key to an insecure location like `electron-store`.
## Phase 2: Automatic Encryption/Decryption via Firestore Converter
This phase aims to abstract away the encryption/decryption logic from the repository layer, making it automatic.
1. **Create `firestoreConverter.js`**:
- Location: `src/common/repositories/firestoreConverter.js`
- Implement a factory function `createEncryptedConverter(fieldsToEncrypt = [])`.
- This function will return a Firestore converter object with `toFirestore` and `fromFirestore` methods.
- `toFirestore`: Will automatically encrypt the specified fields before writing data to Firestore.
- `fromFirestore`: Will automatically decrypt the specified fields after reading data from Firestore.
## Phase 3: Implement Firebase Repositories
With the encryption layer ready, we will create the Firebase equivalents of the existing SQLite repositories.
1. **Create `session/firebase.repository.js`**:
- Location: `src/common/repositories/session/firebase.repository.js`
- Use the `createEncryptedConverter` to encrypt fields like `title`.
- Implement all functions from the SQLite counterpart (`create`, `getById`, `getOrCreateActive`, etc.) using Firestore APIs.
2. **Create `ask/repositories/firebase.repository.js`**:
- Location: `src/features/ask/repositories/firebase.repository.js`
- Use the `createEncryptedConverter` to encrypt the `content` field of AI messages.
- Implement all functions from the SQLite counterpart (`addAiMessage`, `getAllAiMessagesBySessionId`).
## Phase 4: Integrate Repository Strategy Pattern
This final phase will activate the logic that switches between local and remote databases based on user authentication status.
1. **Update `getRepository()` functions**:
- Modify `src/common/repositories/session/index.js` and `src/features/ask/repositories/index.js`.
- In the `getRepository()` function, use `authService.getCurrentUser()` to check if the user is logged in (`user.isLoggedIn`).
- If logged in, return the `firebaseRepository`.
- Otherwise, return the `sqliteRepository`.
- Uncomment the `require` statements for the newly created Firebase repositories.

View File

@ -1,77 +0,0 @@
# Repository API Report
이 문서는 각 리포지토리 모듈의 공개 API 명세를 정리합니다. 모든 서비스 레이어는 여기에 명시된 함수 시그니처를 따라야 합니다. `uid`는 어댑터 레이어에서 자동으로 주입되므로 서비스 레이어에서 전달해서는 안 됩니다.
---
### Session Repository
**Path:** `src/common/repositories/session/`
- `getById(id: string)`
- `create(type: 'ask' | 'listen' = 'ask')`
- `getAllByUserId()`
- `updateTitle(id: string, title: string)`
- `deleteWithRelatedData(id:string)`
- `end(id: string)`
- `updateType(id: string, type: 'ask' | 'listen')`
- `touch(id: string)`
- `getOrCreateActive(requestedType: 'ask' | 'listen' = 'ask')`
- `endAllActiveSessions()`
---
### User Repository
**Path:** `src/common/repositories/user/`
- `findOrCreate(user: object)`
- `getById()`
- `saveApiKey(apiKey: string, provider: string)`
- `update(updateData: object)`
- `deleteById()`
---
### Preset Repository
**Path:** `src/common/repositories/preset/`
- `getPresets()`
- `getPresetTemplates()`
- `create(options: { title: string, prompt: string })`
- `update(id: string, options: { title: string, prompt: string })`
- `delete(id: string)`
---
### Ask Repository (AI Messages)
**Path:** `src/features/ask/repositories/`
- `addAiMessage(options: { sessionId: string, role: string, content: string, model?: string })`
- `getAllAiMessagesBySessionId(sessionId: string)`
---
### STT Repository (Transcripts)
**Path:** `src/features/listen/stt/repositories/`
- `addTranscript(options: { sessionId: string, speaker: string, text: string })`
- `getAllTranscriptsBySessionId(sessionId: string)`
---
### Summary Repository
**Path:** `src/features/listen/summary/repositories/`
- `saveSummary(options: { sessionId: string, tldr: string, text: string, bullet_json: string, action_json: string, model?: string })`
- `getSummaryBySessionId(sessionId: string)`
---
### Settings Repository (Presets)
**Path:** `src/features/settings/repositories/`
*(Note: This is largely a duplicate of the main Preset Repository and might be a candidate for future refactoring.)*
- `getPresets()`
- `getPresetTemplates()`
- `createPreset(options: { title: string, prompt: string })`
- `updatePreset(id: string, options: { title: string, prompt: string })`
- `deletePreset(id: string)`

View File

@ -7,7 +7,8 @@ const LATEST_SCHEMA = {
{ name: 'created_at', type: 'INTEGER' }, { name: 'created_at', type: 'INTEGER' },
{ name: 'api_key', type: 'TEXT' }, { name: 'api_key', type: 'TEXT' },
{ name: 'provider', type: 'TEXT DEFAULT \'openai\'' }, { name: 'provider', type: 'TEXT DEFAULT \'openai\'' },
{ name: 'auto_update_enabled', type: 'INTEGER DEFAULT 1' } { name: 'auto_update_enabled', type: 'INTEGER DEFAULT 1' },
{ name: 'has_migrated_to_firebase', type: 'INTEGER DEFAULT 0' }
] ]
}, },
sessions: { sessions: {

View File

@ -1,4 +1,5 @@
const encryptionService = require('../services/encryptionService'); const encryptionService = require('../services/encryptionService');
const { Timestamp } = require('firebase/firestore');
/** /**
* Creates a Firestore converter that automatically encrypts and decrypts specified fields. * Creates a Firestore converter that automatically encrypts and decrypts specified fields.
@ -19,7 +20,7 @@ function createEncryptedConverter(fieldsToEncrypt = []) {
} }
} }
// Ensure there's a timestamp for the last modification // Ensure there's a timestamp for the last modification
firestoreData.updated_at = Math.floor(Date.now() / 1000); firestoreData.updated_at = Timestamp.now();
return firestoreData; return firestoreData;
}, },
/** /**
@ -35,6 +36,14 @@ function createEncryptedConverter(fieldsToEncrypt = []) {
appObject[field] = encryptionService.decrypt(appObject[field]); appObject[field] = encryptionService.decrypt(appObject[field]);
} }
} }
// Convert Firestore Timestamps back to Unix timestamps (seconds) for app-wide consistency
for (const key in appObject) {
if (appObject[key] instanceof Timestamp) {
appObject[key] = appObject[key].seconds;
}
}
return appObject; return appObject;
} }
}; };

View File

@ -1,5 +1,7 @@
const { getFirestore, collection, doc, addDoc, getDoc, getDocs, updateDoc, deleteDoc, query, where, orderBy } = require('firebase/firestore'); const { collection, doc, addDoc, getDoc, getDocs, updateDoc, deleteDoc, query, where, orderBy, Timestamp } = require('firebase/firestore');
const { getFirestoreInstance } = require('../../services/firebaseClient');
const { createEncryptedConverter } = require('../firestoreConverter'); const { createEncryptedConverter } = require('../firestoreConverter');
const encryptionService = require('../../services/encryptionService');
const userPresetConverter = createEncryptedConverter(['prompt', 'title']); const userPresetConverter = createEncryptedConverter(['prompt', 'title']);
@ -12,13 +14,14 @@ const defaultPresetConverter = {
}; };
function userPresetsCol() { function userPresetsCol() {
const db = getFirestore(); const db = getFirestoreInstance();
return collection(db, 'prompt_presets').withConverter(userPresetConverter); return collection(db, 'prompt_presets').withConverter(userPresetConverter);
} }
function defaultPresetsCol() { function defaultPresetsCol() {
const db = getFirestore(); const db = getFirestoreInstance();
return collection(db, 'defaults/prompt_presets').withConverter(defaultPresetConverter); // Path must have an odd number of segments. 'v1' is a placeholder document.
return collection(db, 'defaults/v1/prompt_presets').withConverter(defaultPresetConverter);
} }
async function getPresets(uid) { async function getPresets(uid) {
@ -49,12 +52,12 @@ async function getPresetTemplates() {
} }
async function create({ uid, title, prompt }) { async function create({ uid, title, prompt }) {
const now = Math.floor(Date.now() / 1000); const now = Timestamp.now();
const newPreset = { const newPreset = {
uid: uid, uid: uid,
title, title,
prompt, prompt,
is_default: false, is_default: 0,
created_at: now, created_at: now,
}; };
const docRef = await addDoc(userPresetsCol(), newPreset); const docRef = await addDoc(userPresetsCol(), newPreset);
@ -68,8 +71,18 @@ async function update(id, { title, prompt }, uid) {
if (!docSnap.exists() || docSnap.data().uid !== uid || docSnap.data().is_default) { if (!docSnap.exists() || docSnap.data().uid !== uid || docSnap.data().is_default) {
throw new Error("Preset not found or permission denied to update."); throw new Error("Preset not found or permission denied to update.");
} }
await updateDoc(docRef, { title, prompt }); // Encrypt sensitive fields before sending to Firestore because `updateDoc` bypasses converters.
const updates = {};
if (title !== undefined) {
updates.title = encryptionService.encrypt(title);
}
if (prompt !== undefined) {
updates.prompt = encryptionService.encrypt(prompt);
}
updates.updated_at = Timestamp.now();
await updateDoc(docRef, updates);
return { changes: 1 }; return { changes: 1 };
} }

View File

@ -1,16 +1,18 @@
const { getFirestore, doc, getDoc, collection, addDoc, query, where, getDocs, writeBatch, orderBy, limit, runTransaction, updateDoc } = require('firebase/firestore'); const { doc, getDoc, collection, addDoc, query, where, getDocs, writeBatch, orderBy, limit, updateDoc, Timestamp } = require('firebase/firestore');
const { getFirestoreInstance } = require('../../services/firebaseClient');
const { createEncryptedConverter } = require('../firestoreConverter'); const { createEncryptedConverter } = require('../firestoreConverter');
const encryptionService = require('../../services/encryptionService');
const sessionConverter = createEncryptedConverter(['title']); const sessionConverter = createEncryptedConverter(['title']);
function sessionsCol() { function sessionsCol() {
const db = getFirestore(); const db = getFirestoreInstance();
return collection(db, 'sessions').withConverter(sessionConverter); return collection(db, 'sessions').withConverter(sessionConverter);
} }
// Sub-collection references are now built from the top-level // Sub-collection references are now built from the top-level
function subCollections(sessionId) { function subCollections(sessionId) {
const db = getFirestore(); const db = getFirestoreInstance();
const sessionPath = `sessions/${sessionId}`; const sessionPath = `sessions/${sessionId}`;
return { return {
transcripts: collection(db, `${sessionPath}/transcripts`), transcripts: collection(db, `${sessionPath}/transcripts`),
@ -26,7 +28,7 @@ async function getById(id) {
} }
async function create(uid, type = 'ask') { async function create(uid, type = 'ask') {
const now = Math.floor(Date.now() / 1000); const now = Timestamp.now();
const newSession = { const newSession = {
uid: uid, uid: uid,
members: [uid], // For future sharing functionality members: [uid], // For future sharing functionality
@ -49,12 +51,15 @@ async function getAllByUserId(uid) {
async function updateTitle(id, title) { async function updateTitle(id, title) {
const docRef = doc(sessionsCol(), id); const docRef = doc(sessionsCol(), id);
await updateDoc(docRef, { title }); await updateDoc(docRef, {
title: encryptionService.encrypt(title),
updated_at: Timestamp.now()
});
return { changes: 1 }; return { changes: 1 };
} }
async function deleteWithRelatedData(id) { async function deleteWithRelatedData(id) {
const db = getFirestore(); const db = getFirestoreInstance();
const batch = writeBatch(db); const batch = writeBatch(db);
const { transcripts, ai_messages, summary } = subCollections(id); const { transcripts, ai_messages, summary } = subCollections(id);
@ -77,7 +82,7 @@ async function deleteWithRelatedData(id) {
async function end(id) { async function end(id) {
const docRef = doc(sessionsCol(), id); const docRef = doc(sessionsCol(), id);
await updateDoc(docRef, { ended_at: Math.floor(Date.now() / 1000) }); await updateDoc(docRef, { ended_at: Timestamp.now() });
return { changes: 1 }; return { changes: 1 };
} }
@ -89,7 +94,7 @@ async function updateType(id, type) {
async function touch(id) { async function touch(id) {
const docRef = doc(sessionsCol(), id); const docRef = doc(sessionsCol(), id);
await updateDoc(docRef, { updated_at: Math.floor(Date.now() / 1000) }); await updateDoc(docRef, { updated_at: Timestamp.now() });
return { changes: 1 }; return { changes: 1 };
} }
@ -111,7 +116,7 @@ async function getOrCreateActive(uid, requestedType = 'ask') {
console.log(`[Repo] Found active Firebase session ${activeSession.id}`); console.log(`[Repo] Found active Firebase session ${activeSession.id}`);
const updates = { updated_at: Math.floor(Date.now() / 1000) }; const updates = { updated_at: Timestamp.now() };
if (activeSession.session_type === 'ask' && requestedType === 'listen') { if (activeSession.session_type === 'ask' && requestedType === 'listen') {
updates.session_type = 'listen'; updates.session_type = 'listen';
console.log(`[Repo] Promoted Firebase session ${activeSession.id} to 'listen' type.`); console.log(`[Repo] Promoted Firebase session ${activeSession.id} to 'listen' type.`);
@ -131,9 +136,10 @@ async function endAllActiveSessions(uid) {
if (snapshot.empty) return { changes: 0 }; if (snapshot.empty) return { changes: 0 };
const batch = writeBatch(getFirestore()); const batch = writeBatch(getFirestoreInstance());
const now = Timestamp.now();
snapshot.forEach(d => { snapshot.forEach(d => {
batch.update(d.ref, { ended_at: Math.floor(Date.now() / 1000) }); batch.update(d.ref, { ended_at: now });
}); });
await batch.commit(); await batch.commit();

View File

@ -1,8 +1,18 @@
const sqliteRepository = require('./sqlite.repository'); const sqliteRepository = require('./sqlite.repository');
const firebaseRepository = require('./firebase.repository'); const firebaseRepository = require('./firebase.repository');
const authService = require('../../../common/services/authService');
let authService = null;
function setAuthService(service) {
authService = service;
}
function getBaseRepository() { function getBaseRepository() {
if (!authService) {
// Fallback or error if authService is not set, to prevent crashes.
// During initial load, it might not be set, so we default to sqlite.
return sqliteRepository;
}
const user = authService.getCurrentUser(); const user = authService.getCurrentUser();
if (user && user.isLoggedIn) { if (user && user.isLoggedIn) {
return firebaseRepository; return firebaseRepository;
@ -12,6 +22,8 @@ function getBaseRepository() {
// The adapter layer that injects the UID // The adapter layer that injects the UID
const sessionRepositoryAdapter = { const sessionRepositoryAdapter = {
setAuthService, // Expose the setter
getById: (id) => getBaseRepository().getById(id), getById: (id) => getBaseRepository().getById(id),
create: (type = 'ask') => { create: (type = 'ask') => {

View File

@ -1,10 +1,12 @@
const { getFirestore, doc, getDoc, setDoc, deleteDoc, writeBatch, query, where, getDocs, collection } = require('firebase/firestore'); const { doc, getDoc, setDoc, deleteDoc, writeBatch, query, where, getDocs, collection, Timestamp } = require('firebase/firestore');
const { getFirestoreInstance } = require('../../services/firebaseClient');
const { createEncryptedConverter } = require('../firestoreConverter'); const { createEncryptedConverter } = require('../firestoreConverter');
const encryptionService = require('../../services/encryptionService');
const userConverter = createEncryptedConverter(['api_key']); const userConverter = createEncryptedConverter(['api_key']);
function usersCol() { function usersCol() {
const db = getFirestore(); const db = getFirestoreInstance();
return collection(db, 'users').withConverter(userConverter); return collection(db, 'users').withConverter(userConverter);
} }
@ -14,7 +16,7 @@ function usersCol() {
async function findOrCreate(user) { async function findOrCreate(user) {
if (!user || !user.uid) throw new Error('User object and uid are required'); if (!user || !user.uid) throw new Error('User object and uid are required');
const { uid, displayName, email } = user; const { uid, displayName, email } = user;
const now = Math.floor(Date.now() / 1000); const now = Timestamp.now();
const docRef = doc(usersCol(), uid); const docRef = doc(usersCol(), uid);
const docSnap = await getDoc(docRef); const docSnap = await getDoc(docRef);
@ -49,7 +51,7 @@ async function update({ uid, displayName }) {
} }
async function deleteById(uid) { async function deleteById(uid) {
const db = getFirestore(); const db = getFirestoreInstance();
const batch = writeBatch(db); const batch = writeBatch(db);
// 1. Delete all sessions owned by the user // 1. Delete all sessions owned by the user

View File

@ -58,6 +58,16 @@ function update({ uid, displayName }) {
return { changes: result.changes }; return { changes: result.changes };
} }
function setMigrationComplete(uid) {
const db = sqliteClient.getDb();
const stmt = db.prepare('UPDATE users SET has_migrated_to_firebase = 1 WHERE uid = ?');
const result = stmt.run(uid);
if (result.changes > 0) {
console.log(`[Repo] Marked migration as complete for user ${uid}.`);
}
return result;
}
function deleteById(uid) { function deleteById(uid) {
const db = sqliteClient.getDb(); const db = sqliteClient.getDb();
const userSessions = db.prepare('SELECT id FROM sessions WHERE uid = ?').all(uid); const userSessions = db.prepare('SELECT id FROM sessions WHERE uid = ?').all(uid);
@ -88,5 +98,6 @@ module.exports = {
getById, getById,
saveApiKey, saveApiKey,
update, update,
setMigrationComplete,
deleteById deleteById
}; };

View File

@ -3,6 +3,8 @@ const { BrowserWindow } = require('electron');
const { getFirebaseAuth } = require('./firebaseClient'); const { getFirebaseAuth } = require('./firebaseClient');
const fetch = require('node-fetch'); const fetch = require('node-fetch');
const encryptionService = require('./encryptionService'); const encryptionService = require('./encryptionService');
const migrationService = require('./migrationService');
const sessionRepository = require('../repositories/session');
async function getVirtualKeyByEmail(email, idToken) { async function getVirtualKeyByEmail(email, idToken) {
if (!idToken) { if (!idToken) {
@ -47,6 +49,12 @@ class AuthService {
initialize() { initialize() {
if (this.isInitialized) return this.initializationPromise; if (this.isInitialized) return this.initializationPromise;
// --- Break the circular dependency ---
// Inject this authService instance into the session repository so it can be used
// without a direct `require` cycle.
sessionRepository.setAuthService(this);
// --- End of dependency injection ---
this.initializationPromise = new Promise((resolve) => { this.initializationPromise = new Promise((resolve) => {
const auth = getFirebaseAuth(); const auth = getFirebaseAuth();
onAuthStateChanged(auth, async (user) => { onAuthStateChanged(auth, async (user) => {
@ -59,9 +67,16 @@ class AuthService {
this.currentUserId = user.uid; this.currentUserId = user.uid;
this.currentUserMode = 'firebase'; this.currentUserMode = 'firebase';
// Clean up any zombie sessions from a previous run for this user.
await sessionRepository.endAllActiveSessions();
// ** Initialize encryption key for the logged-in user ** // ** Initialize encryption key for the logged-in user **
await encryptionService.initializeKey(user.uid); await encryptionService.initializeKey(user.uid);
// ** Check for and run data migration for the user **
// No 'await' here, so it runs in the background without blocking startup.
migrationService.checkAndRunMigration(user);
// Start background task to fetch and save virtual key // Start background task to fetch and save virtual key
(async () => { (async () => {
@ -92,6 +107,9 @@ class AuthService {
this.currentUserId = 'default_user'; this.currentUserId = 'default_user';
this.currentUserMode = 'local'; this.currentUserMode = 'local';
// End active sessions for the local/default user as well.
await sessionRepository.endAllActiveSessions();
// ** Initialize encryption key for the default/local user ** // ** Initialize encryption key for the default/local user **
await encryptionService.initializeKey(this.currentUserId); await encryptionService.initializeKey(this.currentUserId);
} }
@ -123,6 +141,9 @@ class AuthService {
async signOut() { async signOut() {
const auth = getFirebaseAuth(); const auth = getFirebaseAuth();
try { try {
// End all active sessions for the current user BEFORE signing out.
await sessionRepository.endAllActiveSessions();
await signOut(auth); await signOut(auth);
console.log('[AuthService] User sign-out initiated successfully.'); console.log('[AuthService] User sign-out initiated successfully.');
// onAuthStateChanged will handle the state update and broadcast, // onAuthStateChanged will handle the state update and broadcast,

View File

@ -1,9 +1,9 @@
const { initializeApp } = require('firebase/app'); const { initializeApp } = require('firebase/app');
const { initializeAuth } = require('firebase/auth'); const { initializeAuth } = require('firebase/auth');
const Store = require('electron-store'); const Store = require('electron-store');
const { setLogLevel } = require('firebase/firestore'); const { getFirestore, setLogLevel } = require('firebase/firestore');
setLogLevel('debug'); // setLogLevel('debug');
/** /**
* Firebase Auth expects the `persistence` option passed to `initializeAuth()` to be *classes*, * Firebase Auth expects the `persistence` option passed to `initializeAuth()` to be *classes*,
@ -69,6 +69,7 @@ const firebaseConfig = {
let firebaseApp = null; let firebaseApp = null;
let firebaseAuth = null; let firebaseAuth = null;
let firestoreInstance = null; // To hold the specific DB instance
function initializeFirebase() { function initializeFirebase() {
if (firebaseApp) { if (firebaseApp) {
@ -87,7 +88,11 @@ function initializeFirebase() {
persistence: [ElectronStorePersistence], persistence: [ElectronStorePersistence],
}); });
// Initialize Firestore with the specific database ID
firestoreInstance = getFirestore(firebaseApp, 'pickle-glass');
console.log('[FirebaseClient] Firebase initialized successfully with class-based electron-store persistence.'); console.log('[FirebaseClient] Firebase initialized successfully with class-based electron-store persistence.');
console.log('[FirebaseClient] Firestore instance is targeting the "pickle-glass" database.');
} catch (error) { } catch (error) {
console.error('[FirebaseClient] Firebase initialization failed:', error); console.error('[FirebaseClient] Firebase initialization failed:', error);
} }
@ -100,7 +105,15 @@ function getFirebaseAuth() {
return firebaseAuth; return firebaseAuth;
} }
function getFirestoreInstance() {
if (!firestoreInstance) {
throw new Error("Firestore has not been initialized. Call initializeFirebase() first.");
}
return firestoreInstance;
}
module.exports = { module.exports = {
initializeFirebase, initializeFirebase,
getFirebaseAuth, getFirebaseAuth,
getFirestoreInstance,
}; };

View File

@ -0,0 +1,192 @@
const { doc, writeBatch, Timestamp } = require('firebase/firestore');
const { getFirestoreInstance } = require('../services/firebaseClient');
const encryptionService = require('../services/encryptionService');
const sqliteSessionRepo = require('../repositories/session/sqlite.repository');
const sqlitePresetRepo = require('../repositories/preset/sqlite.repository');
const sqliteUserRepo = require('../repositories/user/sqlite.repository');
const sqliteSttRepo = require('../../features/listen/stt/repositories/sqlite.repository');
const sqliteSummaryRepo = require('../../features/listen/summary/repositories/sqlite.repository');
const sqliteAiMessageRepo = require('../../features/ask/repositories/sqlite.repository');
const MAX_BATCH_OPERATIONS = 500;
async function checkAndRunMigration(firebaseUser) {
if (!firebaseUser || !firebaseUser.uid) {
console.log('[Migration] No user, skipping migration check.');
return;
}
console.log(`[Migration] Checking for user ${firebaseUser.uid}...`);
const localUser = sqliteUserRepo.getById(firebaseUser.uid);
if (!localUser || localUser.has_migrated_to_firebase) {
console.log(`[Migration] User ${firebaseUser.uid} is not eligible or already migrated.`);
return;
}
console.log(`[Migration] Starting data migration for user ${firebaseUser.uid}...`);
try {
const db = getFirestoreInstance();
// --- Phase 1: Migrate Parent Documents (Presets & Sessions) ---
console.log('[Migration Phase 1] Migrating parent documents...');
let phase1Batch = writeBatch(db);
let phase1OpCount = 0;
const phase1Promises = [];
const localPresets = (await sqlitePresetRepo.getPresets(firebaseUser.uid)).filter(p => !p.is_default);
console.log(`[Migration Phase 1] Found ${localPresets.length} custom presets.`);
for (const preset of localPresets) {
const presetRef = doc(db, 'prompt_presets', preset.id);
const cleanPreset = {
uid: preset.uid,
title: encryptionService.encrypt(preset.title ?? ''),
prompt: encryptionService.encrypt(preset.prompt ?? ''),
is_default: preset.is_default ?? 0,
created_at: preset.created_at ? Timestamp.fromMillis(preset.created_at * 1000) : null,
updated_at: preset.updated_at ? Timestamp.fromMillis(preset.updated_at * 1000) : null
};
phase1Batch.set(presetRef, cleanPreset);
phase1OpCount++;
if (phase1OpCount >= MAX_BATCH_OPERATIONS) {
phase1Promises.push(phase1Batch.commit());
phase1Batch = writeBatch(db);
phase1OpCount = 0;
}
}
const localSessions = await sqliteSessionRepo.getAllByUserId(firebaseUser.uid);
console.log(`[Migration Phase 1] Found ${localSessions.length} sessions.`);
for (const session of localSessions) {
const sessionRef = doc(db, 'sessions', session.id);
const cleanSession = {
uid: session.uid,
members: session.members ?? [session.uid],
title: encryptionService.encrypt(session.title ?? ''),
session_type: session.session_type ?? 'ask',
started_at: session.started_at ? Timestamp.fromMillis(session.started_at * 1000) : null,
ended_at: session.ended_at ? Timestamp.fromMillis(session.ended_at * 1000) : null,
updated_at: session.updated_at ? Timestamp.fromMillis(session.updated_at * 1000) : null
};
phase1Batch.set(sessionRef, cleanSession);
phase1OpCount++;
if (phase1OpCount >= MAX_BATCH_OPERATIONS) {
phase1Promises.push(phase1Batch.commit());
phase1Batch = writeBatch(db);
phase1OpCount = 0;
}
}
if (phase1OpCount > 0) {
phase1Promises.push(phase1Batch.commit());
}
if (phase1Promises.length > 0) {
await Promise.all(phase1Promises);
console.log(`[Migration Phase 1] Successfully committed ${phase1Promises.length} batches of parent documents.`);
} else {
console.log('[Migration Phase 1] No parent documents to migrate.');
}
// --- Phase 2: Migrate Child Documents (sub-collections) ---
console.log('[Migration Phase 2] Migrating child documents for all sessions...');
let phase2Batch = writeBatch(db);
let phase2OpCount = 0;
const phase2Promises = [];
for (const session of localSessions) {
const transcripts = await sqliteSttRepo.getAllTranscriptsBySessionId(session.id);
for (const t of transcripts) {
const transcriptRef = doc(db, `sessions/${session.id}/transcripts`, t.id);
const cleanTranscript = {
uid: firebaseUser.uid,
session_id: t.session_id,
start_at: t.start_at ? Timestamp.fromMillis(t.start_at * 1000) : null,
end_at: t.end_at ? Timestamp.fromMillis(t.end_at * 1000) : null,
speaker: t.speaker ?? null,
text: encryptionService.encrypt(t.text ?? ''),
lang: t.lang ?? 'en',
created_at: t.created_at ? Timestamp.fromMillis(t.created_at * 1000) : null
};
phase2Batch.set(transcriptRef, cleanTranscript);
phase2OpCount++;
if (phase2OpCount >= MAX_BATCH_OPERATIONS) {
phase2Promises.push(phase2Batch.commit());
phase2Batch = writeBatch(db);
phase2OpCount = 0;
}
}
const messages = await sqliteAiMessageRepo.getAllAiMessagesBySessionId(session.id);
for (const m of messages) {
const msgRef = doc(db, `sessions/${session.id}/ai_messages`, m.id);
const cleanMessage = {
uid: firebaseUser.uid,
session_id: m.session_id,
sent_at: m.sent_at ? Timestamp.fromMillis(m.sent_at * 1000) : null,
role: m.role ?? 'user',
content: encryptionService.encrypt(m.content ?? ''),
tokens: m.tokens ?? null,
model: m.model ?? 'unknown',
created_at: m.created_at ? Timestamp.fromMillis(m.created_at * 1000) : null
};
phase2Batch.set(msgRef, cleanMessage);
phase2OpCount++;
if (phase2OpCount >= MAX_BATCH_OPERATIONS) {
phase2Promises.push(phase2Batch.commit());
phase2Batch = writeBatch(db);
phase2OpCount = 0;
}
}
const summary = await sqliteSummaryRepo.getSummaryBySessionId(session.id);
if (summary) {
// Reverting to use 'data' as the document ID for summary.
const summaryRef = doc(db, `sessions/${session.id}/summary`, 'data');
const cleanSummary = {
uid: firebaseUser.uid,
session_id: summary.session_id,
generated_at: summary.generated_at ? Timestamp.fromMillis(summary.generated_at * 1000) : null,
model: summary.model ?? 'unknown',
tldr: encryptionService.encrypt(summary.tldr ?? ''),
text: encryptionService.encrypt(summary.text ?? ''),
bullet_json: encryptionService.encrypt(summary.bullet_json ?? '[]'),
action_json: encryptionService.encrypt(summary.action_json ?? '[]'),
tokens_used: summary.tokens_used ?? null,
updated_at: summary.updated_at ? Timestamp.fromMillis(summary.updated_at * 1000) : null
};
phase2Batch.set(summaryRef, cleanSummary);
phase2OpCount++;
if (phase2OpCount >= MAX_BATCH_OPERATIONS) {
phase2Promises.push(phase2Batch.commit());
phase2Batch = writeBatch(db);
phase2OpCount = 0;
}
}
}
if (phase2OpCount > 0) {
phase2Promises.push(phase2Batch.commit());
}
if (phase2Promises.length > 0) {
await Promise.all(phase2Promises);
console.log(`[Migration Phase 2] Successfully committed ${phase2Promises.length} batches of child documents.`);
} else {
console.log('[Migration Phase 2] No child documents to migrate.');
}
// --- 4. Mark migration as complete ---
sqliteUserRepo.setMigrationComplete(firebaseUser.uid);
console.log(`[Migration] ✅ Successfully marked migration as complete for ${firebaseUser.uid}.`);
} catch (error) {
console.error(`[Migration] 🔥 An error occurred during migration for user ${firebaseUser.uid}:`, error);
}
}
module.exports = {
checkAndRunMigration,
};

View File

@ -28,9 +28,18 @@ async function sendMessage(userPrompt) {
askWindow.webContents.send('hide-text-input'); askWindow.webContents.send('hide-text-input');
} }
let sessionId;
try { try {
console.log(`[AskService] 🤖 Processing message: ${userPrompt.substring(0, 50)}...`); console.log(`[AskService] 🤖 Processing message: ${userPrompt.substring(0, 50)}...`);
// --- Save user's message immediately ---
// This ensures the user message is always timestamped before the assistant's response.
sessionId = await sessionRepository.getOrCreateActive('ask');
await askRepository.addAiMessage({ sessionId, role: 'user', content: userPrompt.trim() });
console.log(`[AskService] DB: Saved user prompt to session ${sessionId}`);
// --- End of user message saving ---
const modelInfo = await getCurrentModelInfo(null, { type: 'llm' }); const modelInfo = await getCurrentModelInfo(null, { type: 'llm' });
if (!modelInfo || !modelInfo.apiKey) { if (!modelInfo || !modelInfo.apiKey) {
throw new Error('AI model or API key not configured.'); throw new Error('AI model or API key not configured.');
@ -99,15 +108,13 @@ async function sendMessage(userPrompt) {
if (data === '[DONE]') { if (data === '[DONE]') {
askWin.webContents.send('ask-response-stream-end'); askWin.webContents.send('ask-response-stream-end');
// Save to DB // Save assistant's message to DB
try { try {
// The repository adapter will now handle the UID internally. // sessionId is already available from when we saved the user prompt
const sessionId = await sessionRepository.getOrCreateActive('ask');
await askRepository.addAiMessage({ sessionId, role: 'user', content: userPrompt.trim() });
await askRepository.addAiMessage({ sessionId, role: 'assistant', content: fullResponse }); await askRepository.addAiMessage({ sessionId, role: 'assistant', content: fullResponse });
console.log(`[AskService] DB: Saved ask/answer pair to session ${sessionId}`); console.log(`[AskService] DB: Saved assistant response to session ${sessionId}`);
} catch(dbError) { } catch(dbError) {
console.error("[AskService] DB: Failed to save ask/answer pair:", dbError); console.error("[AskService] DB: Failed to save assistant response:", dbError);
} }
return { success: true, response: fullResponse }; return { success: true, response: fullResponse };

View File

@ -1,16 +1,17 @@
const { getFirestore, collection, addDoc, query, getDocs, orderBy } = require('firebase/firestore'); const { collection, addDoc, query, getDocs, orderBy, Timestamp } = require('firebase/firestore');
const { getFirestoreInstance } = require('../../../common/services/firebaseClient');
const { createEncryptedConverter } = require('../../../common/repositories/firestoreConverter'); const { createEncryptedConverter } = require('../../../common/repositories/firestoreConverter');
const aiMessageConverter = createEncryptedConverter(['content']); const aiMessageConverter = createEncryptedConverter(['content']);
function aiMessagesCol(sessionId) { function aiMessagesCol(sessionId) {
if (!sessionId) throw new Error("Session ID is required to access AI messages."); if (!sessionId) throw new Error("Session ID is required to access AI messages.");
const db = getFirestore(); const db = getFirestoreInstance();
return collection(db, `sessions/${sessionId}/ai_messages`).withConverter(aiMessageConverter); return collection(db, `sessions/${sessionId}/ai_messages`).withConverter(aiMessageConverter);
} }
async function addAiMessage({ uid, sessionId, role, content, model = 'unknown' }) { async function addAiMessage({ uid, sessionId, role, content, model = 'unknown' }) {
const now = Math.floor(Date.now() / 1000); const now = Timestamp.now();
const newMessage = { const newMessage = {
uid, // To identify the author of the message uid, // To identify the author of the message
session_id: sessionId, session_id: sessionId,

View File

@ -1,16 +1,17 @@
const { getFirestore, collection, addDoc, query, getDocs, orderBy } = require('firebase/firestore'); const { collection, addDoc, query, getDocs, orderBy, Timestamp } = require('firebase/firestore');
const { getFirestoreInstance } = require('../../../../common/services/firebaseClient');
const { createEncryptedConverter } = require('../../../../common/repositories/firestoreConverter'); const { createEncryptedConverter } = require('../../../../common/repositories/firestoreConverter');
const transcriptConverter = createEncryptedConverter(['text']); const transcriptConverter = createEncryptedConverter(['text']);
function transcriptsCol(sessionId) { function transcriptsCol(sessionId) {
if (!sessionId) throw new Error("Session ID is required to access transcripts."); if (!sessionId) throw new Error("Session ID is required to access transcripts.");
const db = getFirestore(); const db = getFirestoreInstance();
return collection(db, `sessions/${sessionId}/transcripts`).withConverter(transcriptConverter); return collection(db, `sessions/${sessionId}/transcripts`).withConverter(transcriptConverter);
} }
async function addTranscript({ uid, sessionId, speaker, text }) { async function addTranscript({ uid, sessionId, speaker, text }) {
const now = Math.floor(Date.now() / 1000); const now = Timestamp.now();
const newTranscript = { const newTranscript = {
uid, // To identify the author/source of the transcript uid, // To identify the author/source of the transcript
session_id: sessionId, session_id: sessionId,

View File

@ -1,20 +1,24 @@
const { getFirestore, collection, doc, setDoc, getDoc } = require('firebase/firestore'); const { collection, doc, setDoc, getDoc, Timestamp } = require('firebase/firestore');
const { getFirestoreInstance } = require('../../../../common/services/firebaseClient');
const { createEncryptedConverter } = require('../../../../common/repositories/firestoreConverter'); const { createEncryptedConverter } = require('../../../../common/repositories/firestoreConverter');
const encryptionService = require('../../../../common/services/encryptionService');
const fieldsToEncrypt = ['tldr', 'text', 'bullet_json', 'action_json']; const fieldsToEncrypt = ['tldr', 'text', 'bullet_json', 'action_json'];
const summaryConverter = createEncryptedConverter(fieldsToEncrypt); const summaryConverter = createEncryptedConverter(fieldsToEncrypt);
function summaryDocRef(sessionId) { function summaryDocRef(sessionId) {
if (!sessionId) throw new Error("Session ID is required to access summary."); if (!sessionId) throw new Error("Session ID is required to access summary.");
const db = getFirestore(); const db = getFirestoreInstance();
const path = `sessions/${sessionId}/summary`; // Reverting to the original structure with 'data' as the document ID.
return doc(collection(db, path).withConverter(summaryConverter), 'data'); const docPath = `sessions/${sessionId}/summary/data`;
return doc(db, docPath).withConverter(summaryConverter);
} }
async function saveSummary({ uid, sessionId, tldr, text, bullet_json, action_json, model = 'unknown' }) { async function saveSummary({ uid, sessionId, tldr, text, bullet_json, action_json, model = 'unknown' }) {
const now = Math.floor(Date.now() / 1000); const now = Timestamp.now();
const summaryData = { const summaryData = {
uid, // To know who generated the summary uid, // To know who generated the summary
session_id: sessionId,
generated_at: now, generated_at: now,
model, model,
text, text,
@ -24,6 +28,8 @@ async function saveSummary({ uid, sessionId, tldr, text, bullet_json, action_jso
updated_at: now, updated_at: now,
}; };
// The converter attached to summaryDocRef will handle encryption via its `toFirestore` method.
// Manual encryption was removed to fix the double-encryption bug.
const docRef = summaryDocRef(sessionId); const docRef = summaryDocRef(sessionId);
await setDoc(docRef, summaryData, { merge: true }); await setDoc(docRef, summaryData, { merge: true });

View File

@ -1,7 +1,9 @@
const { getFirestore, collection, doc, addDoc, getDoc, getDocs, updateDoc, deleteDoc, query, where, orderBy } = require('firebase/firestore'); const { collection, doc, addDoc, getDoc, getDocs, updateDoc, deleteDoc, query, where, orderBy } = require('firebase/firestore');
const { getFirestoreInstance } = require('../../../common/services/firebaseClient');
const { createEncryptedConverter } = require('../../../common/repositories/firestoreConverter'); const { createEncryptedConverter } = require('../../../common/repositories/firestoreConverter');
const encryptionService = require('../../../common/services/encryptionService');
const userPresetConverter = createEncryptedConverter(['prompt']); const userPresetConverter = createEncryptedConverter(['prompt', 'title']);
const defaultPresetConverter = { const defaultPresetConverter = {
toFirestore: (data) => data, toFirestore: (data) => data,
@ -12,13 +14,13 @@ const defaultPresetConverter = {
}; };
function userPresetsCol() { function userPresetsCol() {
const db = getFirestore(); const db = getFirestoreInstance();
return collection(db, 'prompt_presets').withConverter(userPresetConverter); return collection(db, 'prompt_presets').withConverter(userPresetConverter);
} }
function defaultPresetsCol() { function defaultPresetsCol() {
const db = getFirestore(); const db = getFirestoreInstance();
return collection(db, 'defaults/prompt_presets').withConverter(defaultPresetConverter); return collection(db, 'defaults/v1/prompt_presets').withConverter(defaultPresetConverter);
} }
async function getPresets(uid) { async function getPresets(uid) {
@ -54,7 +56,7 @@ async function createPreset({ uid, title, prompt }) {
uid: uid, uid: uid,
title, title,
prompt, prompt,
is_default: false, is_default: 0,
created_at: now, created_at: now,
}; };
const docRef = await addDoc(userPresetsCol(), newPreset); const docRef = await addDoc(userPresetsCol(), newPreset);
@ -68,8 +70,17 @@ async function updatePreset(id, { title, prompt }, uid) {
if (!docSnap.exists() || docSnap.data().uid !== uid || docSnap.data().is_default) { if (!docSnap.exists() || docSnap.data().uid !== uid || docSnap.data().is_default) {
throw new Error("Preset not found or permission denied to update."); throw new Error("Preset not found or permission denied to update.");
} }
const updates = {};
if (title !== undefined) {
updates.title = encryptionService.encrypt(title);
}
if (prompt !== undefined) {
updates.prompt = encryptionService.encrypt(prompt);
}
updates.updated_at = Math.floor(Date.now() / 1000);
await updateDoc(docRef, { title, prompt }); await updateDoc(docRef, updates);
return { changes: 1 }; return { changes: 1 };
} }
@ -87,7 +98,7 @@ async function deletePreset(id, uid) {
async function getAutoUpdate(uid) { async function getAutoUpdate(uid) {
// Assume users are stored in a "users" collection, and auto_update_enabled is a field // Assume users are stored in a "users" collection, and auto_update_enabled is a field
const userDocRef = doc(getFirestore(), 'users', uid); const userDocRef = doc(getFirestoreInstance(), 'users', uid);
try { try {
const userSnap = await getDoc(userDocRef); const userSnap = await getDoc(userDocRef);
if (userSnap.exists()) { if (userSnap.exists()) {
@ -110,7 +121,7 @@ async function getAutoUpdate(uid) {
} }
async function setAutoUpdate(uid, isEnabled) { async function setAutoUpdate(uid, isEnabled) {
const userDocRef = doc(getFirestore(), 'users', uid); const userDocRef = doc(getFirestoreInstance(), 'users', uid);
try { try {
const userSnap = await getDoc(userDocRef); const userSnap = await getDoc(userDocRef);
if (userSnap.exists()) { if (userSnap.exists()) {

View File

@ -187,8 +187,8 @@ app.whenReady().then(async () => {
await databaseInitializer.initialize(); await databaseInitializer.initialize();
console.log('>>> [index.js] Database initialized successfully'); console.log('>>> [index.js] Database initialized successfully');
// Clean up zombie sessions from previous runs first // Clean up zombie sessions from previous runs first - MOVED TO authService
sessionRepository.endAllActiveSessions(); // sessionRepository.endAllActiveSessions();
await authService.initialize(); await authService.initialize();
@ -237,7 +237,7 @@ app.on('window-all-closed', () => {
app.on('before-quit', async () => { app.on('before-quit', async () => {
console.log('[Shutdown] App is about to quit.'); console.log('[Shutdown] App is about to quit.');
listenService.stopMacOSAudioCapture(); listenService.stopMacOSAudioCapture();
await sessionRepository.endAllActiveSessions(); // await sessionRepository.endAllActiveSessions(); // MOVED TO authService
databaseInitializer.close(); databaseInitializer.close();
}); });