diff --git a/src/controllers/googleDrive.controller.ts b/src/controllers/googleDrive.controller.ts new file mode 100644 index 0000000..7cbe75b --- /dev/null +++ b/src/controllers/googleDrive.controller.ts @@ -0,0 +1,187 @@ +import { Request, Response } from 'express'; +import { supabase } from '../lib/supabase'; +import { AuthenticatedRequest } from '../middleware/auth'; +import { encryptToken, decryptToken } from '../lib/tokenCrypto'; +import { createState, consumeState } from '../lib/oauthState'; +import * as googleDrive from '../lib/googleDriveClient'; + +function callbackPage(title: string, message: string): string { + return ` +${title} + +

${title}

${message}

`; +} + +// PRD §19: Electron sadece bir tarayıcı açıp Google'a yönlendirir, tüm OAuth +// kod-değişimi burada (backend'de) yapılır — Electron'a kesinlikle client +// secret veya refresh token gönderilmiyor. +export const connect = async (req: AuthenticatedRequest, res: Response) => { + try { + const userId = req.user?.id as string; + const state = createState(userId); + const authUrl = googleDrive.buildAuthUrl(state); + res.json({ authUrl }); + } catch (error: any) { + console.error('[DRIVE] Connect Error:', error); + res.status(500).json({ error: 'Google Drive bağlantısı başlatılamadı.' }); + } +}; + +// Google'ın doğrudan tarayıcı yönlendirmesiyle çağırdığı tek public uç nokta — +// Bearer token yok, kimlik doğrulama "state" üzerinden yapılıyor (bkz. oauthState.ts). +export const oauthCallback = async (req: Request, res: Response) => { + const { code, state, error: oauthError } = req.query as { code?: string; state?: string; error?: string }; + + if (oauthError) { + console.error('[DRIVE] Google OAuth error:', oauthError); + return res.status(400).send(callbackPage('Bağlantı reddedildi', 'Google Drive bağlantısı tamamlanamadı. Bu pencereyi kapatıp AyrisLegal\'e dönebilirsiniz.')); + } + if (!code || !state) { + return res.status(400).send(callbackPage('Geçersiz istek', 'Eksik parametre. Lütfen AyrisLegal\'den tekrar deneyin.')); + } + + const userId = consumeState(state); + if (!userId) { + console.error('[DRIVE] Geçersiz veya süresi dolmuş OAuth state.'); + return res.status(400).send(callbackPage('Bağlantı süresi doldu', 'Lütfen AyrisLegal\'de "Google Drive\'a Bağlan"ı tekrar deneyin.')); + } + + try { + console.log('[DRIVE] OAuth started, user:', userId); + const tokens = await googleDrive.exchangeCodeForTokens(code); + if (!tokens.refresh_token) { + throw new Error('Google refresh token döndürmedi.'); + } + + const email = await googleDrive.getUserEmail(tokens.access_token); + const { rootFolderId, casesFolderId } = await googleDrive.ensureAyrisLegalFolders(tokens.access_token); + + const { error: dbError } = await supabase.from('google_drive_connections').upsert( + { + user_id: userId, + google_email: email, + access_token_encrypted: encryptToken(tokens.access_token), + refresh_token_encrypted: encryptToken(tokens.refresh_token), + token_expires_at: new Date(Date.now() + tokens.expires_in * 1000).toISOString(), + scope: tokens.scope, + drive_root_folder_id: rootFolderId, + drive_cases_folder_id: casesFolderId, + status: 'connected', + updated_at: new Date().toISOString(), + }, + { onConflict: 'user_id' } + ); + if (dbError) throw dbError; + + console.log('[DRIVE] OAuth completed, user:', userId); + res.send(callbackPage('Bağlantı başarılı', `Google Drive hesabınız (${email}) AyrisLegal'e bağlandı. Bu pencereyi kapatıp uygulamaya dönebilirsiniz.`)); + } catch (error: any) { + console.error('[DRIVE] OAuth callback error:', error); + res.status(500).send(callbackPage('Bağlantı başarısız', 'Google Drive bağlantısı sırasında bir hata oluştu. Lütfen tekrar deneyin.')); + } +}; + +export const getStatus = async (req: AuthenticatedRequest, res: Response) => { + try { + const userId = req.user?.id; + const { data, error } = await supabase + .from('google_drive_connections') + .select('google_email, status, last_sync_at, created_at') + .eq('user_id', userId) + .maybeSingle(); + if (error) throw error; + if (!data) return res.json({ connected: false }); + res.json({ connected: data.status === 'connected', ...data }); + } catch (error: any) { + console.error('[DRIVE] Get Status Error:', error); + res.status(500).json({ error: 'Bağlantı durumu alınamadı.' }); + } +}; + +// PRD §22: local dosyalar VE Drive'daki backup dosyaları asla silinmiyor — +// burada sadece bağlantı/token kaydı kaldırılıyor. +export const disconnect = async (req: AuthenticatedRequest, res: Response) => { + try { + const userId = req.user?.id; + const { data: connection } = await supabase + .from('google_drive_connections') + .select('refresh_token_encrypted') + .eq('user_id', userId) + .maybeSingle(); + + if (connection) { + try { + await googleDrive.revokeToken(decryptToken(connection.refresh_token_encrypted)); + } catch (err: any) { + console.error('[DRIVE] Revoke sırasında hata (yine de bağlantı kaldırılacak):', err.message); + } + } + + const { error } = await supabase.from('google_drive_connections').delete().eq('user_id', userId); + if (error) throw error; + + console.log('[DRIVE] User disconnected:', userId); + res.json({ ok: true }); + } catch (error: any) { + console.error('[DRIVE] Disconnect Error:', error); + res.status(500).json({ error: 'Bağlantı kesilemedi.' }); + } +}; + +// İleride upload/backup-status endpoint'lerinin (Faz 2) kullanacağı iç +// yardımcı — geçerli bir access_token döner, süresi dolmuşsa/dolmak üzereyse +// refresh_token ile otomatik yeniler (PRD §21). Kullanıcıya login sordurmaz. +export async function getValidAccessToken(userId: string): Promise { + const { data: connection, error } = await supabase + .from('google_drive_connections') + .select('access_token_encrypted, refresh_token_encrypted, token_expires_at, status') + .eq('user_id', userId) + .maybeSingle(); + if (error || !connection) { + throw new Error('Google Drive bağlantısı bulunamadı.'); + } + if (connection.status === 'reauthorization_required') { + throw new Error('Google Drive bağlantınızın yenilenmesi gerekiyor.'); + } + + const expiresAt = new Date(connection.token_expires_at).getTime(); + if (expiresAt - Date.now() > 60_000) { + return decryptToken(connection.access_token_encrypted); + } + + try { + const refreshToken = decryptToken(connection.refresh_token_encrypted); + const refreshed = await googleDrive.refreshAccessToken(refreshToken); + console.log('[DRIVE] Token refreshed, user:', userId); + await supabase + .from('google_drive_connections') + .update({ + access_token_encrypted: encryptToken(refreshed.access_token), + token_expires_at: new Date(Date.now() + refreshed.expires_in * 1000).toISOString(), + updated_at: new Date().toISOString(), + }) + .eq('user_id', userId); + return refreshed.access_token; + } catch (err: any) { + if (err.code === 'invalid_grant') { + await supabase.from('google_drive_connections').update({ status: 'reauthorization_required' }).eq('user_id', userId); + } + throw new Error('Google Drive bağlantınızın yenilenmesi gerekiyor.'); + } +} + +export const refresh = async (req: AuthenticatedRequest, res: Response) => { + try { + await getValidAccessToken(req.user?.id as string); + res.json({ ok: true }); + } catch (error: any) { + console.error('[DRIVE] Refresh Error:', error); + res.status(409).json({ error: error.message || 'Google Drive bağlantısı yenilenemedi.' }); + } +}; diff --git a/src/lib/googleDriveClient.ts b/src/lib/googleDriveClient.ts new file mode 100644 index 0000000..e8bf631 --- /dev/null +++ b/src/lib/googleDriveClient.ts @@ -0,0 +1,148 @@ +// Google OAuth + Drive API v3 çağrıları — bilerek googleapis SDK'sı yerine +// düz fetch kullanılıyor (bu birkaç REST çağrısı için ağır bir bağımlılık +// gerekmiyor, projenin geri kalanındaki aiClient.ts ile aynı yaklaşım). +const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; +const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; +const GOOGLE_USERINFO_URL = 'https://www.googleapis.com/oauth2/v2/userinfo'; +const GOOGLE_REVOKE_URL = 'https://oauth2.googleapis.com/revoke'; +const DRIVE_API_URL = 'https://www.googleapis.com/drive/v3'; + +// PRD §7: mümkün olan en düşük scope. drive.file, uygulamanın SADECE kendi +// oluşturduğu/açtığı dosyalara erişmesini sağlar — kullanıcının tüm Drive'ına +// erişim istemiyoruz. +const SCOPE = [ + 'https://www.googleapis.com/auth/drive.file', + 'https://www.googleapis.com/auth/userinfo.email', +].join(' '); + +export interface TokenResponse { + access_token: string; + refresh_token?: string; + expires_in: number; + scope: string; + token_type: string; +} + +function getOAuthConfig() { + const clientId = process.env.GOOGLE_CLIENT_ID; + const clientSecret = process.env.GOOGLE_CLIENT_SECRET; + const redirectUri = process.env.GOOGLE_REDIRECT_URI; + if (!clientId || !clientSecret || !redirectUri) { + throw new Error('GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET veya GOOGLE_REDIRECT_URI tanımlı değil.'); + } + return { clientId, clientSecret, redirectUri }; +} + +export function buildAuthUrl(state: string): string { + const { clientId, redirectUri } = getOAuthConfig(); + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + access_type: 'offline', // refresh_token almak için gerekli + prompt: 'consent', // refresh_token'ın HER bağlantıda dönmesini garantiler + scope: SCOPE, + state, + }); + return `${GOOGLE_AUTH_URL}?${params.toString()}`; +} + +export async function exchangeCodeForTokens(code: string): Promise { + const { clientId, clientSecret, redirectUri } = getOAuthConfig(); + const res = await fetch(GOOGLE_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + }), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error_description || data.error || 'Google token exchange başarısız.'); + } + return data; +} + +export async function refreshAccessToken(refreshToken: string): Promise<{ access_token: string; expires_in: number; scope: string }> { + const { clientId, clientSecret } = getOAuthConfig(); + const res = await fetch(GOOGLE_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + refresh_token: refreshToken, + client_id: clientId, + client_secret: clientSecret, + grant_type: 'refresh_token', + }), + }); + const data = await res.json(); + if (!res.ok) { + const err: any = new Error(data.error_description || data.error || 'Google token yenileme başarısız.'); + err.code = data.error; // 'invalid_grant' → refresh token artık geçersiz + throw err; + } + return data; +} + +// En iyi çaba — Google tarafında revoke başarısız olsa bile disconnect akışı +// yerel kaydı yine de siler (bkz. googleDrive.controller.ts). +export async function revokeToken(token: string): Promise { + try { + await fetch(`${GOOGLE_REVOKE_URL}?token=${encodeURIComponent(token)}`, { method: 'POST' }); + } catch (e: any) { + console.error('[DRIVE] Token revoke isteği başarısız:', e.message); + } +} + +export async function getUserEmail(accessToken: string): Promise { + const res = await fetch(GOOGLE_USERINFO_URL, { headers: { Authorization: `Bearer ${accessToken}` } }); + const data = await res.json(); + if (!res.ok || !data.email) { + throw new Error('Google kullanıcı bilgisi alınamadı.'); + } + return data.email; +} + +// drive.file scope'u sadece uygulamanın oluşturduğu/açtığı dosyaları görmesine +// izin verir — bu yüzden klasörü her zaman biz oluşturuyoruz/arıyoruz, kullanıcının +// genel Drive'ında serbest arama yapamayız (zaten istemiyoruz, bkz. PRD §7). +async function findOrCreateFolder(accessToken: string, name: string, parentId?: string): Promise { + const conditions = [`name='${name.replace(/'/g, "\\'")}'`, "mimeType='application/vnd.google-apps.folder'", 'trashed=false']; + if (parentId) conditions.push(`'${parentId}' in parents`); + + const listRes = await fetch(`${DRIVE_API_URL}/files?q=${encodeURIComponent(conditions.join(' and '))}&fields=files(id,name)`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + const listData = await listRes.json(); + if (listRes.ok && Array.isArray(listData.files) && listData.files.length > 0) { + return listData.files[0].id; + } + + const createRes = await fetch(`${DRIVE_API_URL}/files?fields=id`, { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name, + mimeType: 'application/vnd.google-apps.folder', + parents: parentId ? [parentId] : undefined, + }), + }); + const createData = await createRes.json(); + if (!createRes.ok) { + throw new Error(`Google Drive klasörü oluşturulamadı: ${name}`); + } + return createData.id; +} + +// İlk bağlantıda AyrisLegal/Davalar klasör yapısını kurar (PRD §8). Sonraki +// bağlantılarda (aynı kullanıcı tekrar bağlanırsa) mevcut klasörler bulunup +// tekrar kullanılır, kopya oluşturulmaz. +export async function ensureAyrisLegalFolders(accessToken: string): Promise<{ rootFolderId: string; casesFolderId: string }> { + const rootFolderId = await findOrCreateFolder(accessToken, 'AyrisLegal'); + const casesFolderId = await findOrCreateFolder(accessToken, 'Davalar', rootFolderId); + return { rootFolderId, casesFolderId }; +} diff --git a/src/lib/oauthState.ts b/src/lib/oauthState.ts new file mode 100644 index 0000000..ff47fd1 --- /dev/null +++ b/src/lib/oauthState.ts @@ -0,0 +1,36 @@ +import crypto from 'crypto'; + +// Google OAuth "state" parametresi: CSRF'e karşı kriptografik olarak rastgele, +// kısa ömürlü, tek kullanımlık (bkz. PRD §20). Bellek-içi Map yeterli — OAuth +// akışı saniyeler/dakikalar içinde tamamlanıyor, kalıcı depolamaya gerek yok. +// NOT: backend birden fazla instance'la (load balancer arkasında) çalışırsa bu +// state farklı bir instance'a düşebilir — o zaman paylaşımlı bir store (Redis +// vb.) gerekir. Şu an tek instance varsayımıyla yazıldı. +interface StateEntry { userId: string; expiresAt: number; } + +const STATE_TTL_MS = 10 * 60 * 1000; // 10 dakika +const stateStore = new Map(); + +function cleanupExpired() { + const now = Date.now(); + for (const [key, entry] of stateStore.entries()) { + if (entry.expiresAt < now) stateStore.delete(key); + } +} + +export function createState(userId: string): string { + cleanupExpired(); + const state = crypto.randomBytes(32).toString('hex'); + stateStore.set(state, { userId, expiresAt: Date.now() + STATE_TTL_MS }); + return state; +} + +// Tek kullanımlık — çağrıldığı anda store'dan siliniyor, ikinci kullanım (replay) +// her zaman null döner. +export function consumeState(state: string): string | null { + const entry = stateStore.get(state); + if (!entry) return null; + stateStore.delete(state); + if (entry.expiresAt < Date.now()) return null; + return entry.userId; +} diff --git a/src/lib/tokenCrypto.ts b/src/lib/tokenCrypto.ts new file mode 100644 index 0000000..ea17fa7 --- /dev/null +++ b/src/lib/tokenCrypto.ts @@ -0,0 +1,42 @@ +import crypto from 'crypto'; + +// Google OAuth tokenları asla plaintext saklanmaz (bkz. PRD §6) — AES-256-GCM +// ile şifrelenip tek bir string olarak (iv:authTag:ciphertext, hepsi base64) +// veritabanına yazılır. Node'un yerleşik crypto modülü kullanılıyor, ek +// bağımlılık gerekmiyor. +const ALGORITHM = 'aes-256-gcm'; + +function getKey(): Buffer { + const raw = process.env.GOOGLE_TOKEN_ENCRYPTION_KEY; + if (!raw) { + throw new Error('GOOGLE_TOKEN_ENCRYPTION_KEY ortam değişkeni tanımlı değil.'); + } + const buf = raw.length === 64 ? Buffer.from(raw, 'hex') : Buffer.from(raw, 'base64'); + if (buf.length !== 32) { + throw new Error('GOOGLE_TOKEN_ENCRYPTION_KEY 32 byte olmalı (64 karakterlik hex ya da base64 olarak sağlayın).'); + } + return buf; +} + +export function encryptToken(plaintext: string): string { + const key = getKey(); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + return [iv, authTag, encrypted].map((b) => b.toString('base64')).join(':'); +} + +export function decryptToken(stored: string): string { + const key = getKey(); + const [ivB64, tagB64, dataB64] = stored.split(':'); + if (!ivB64 || !tagB64 || !dataB64) { + throw new Error('Şifrelenmiş token formatı geçersiz.'); + } + const iv = Buffer.from(ivB64, 'base64'); + const authTag = Buffer.from(tagB64, 'base64'); + const data = Buffer.from(dataB64, 'base64'); + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8'); +} diff --git a/src/routes/googleDrive.routes.ts b/src/routes/googleDrive.routes.ts new file mode 100644 index 0000000..811daa8 --- /dev/null +++ b/src/routes/googleDrive.routes.ts @@ -0,0 +1,15 @@ +import { Router } from 'express'; +import { requireAuth } from '../middleware/auth'; +import { connect, oauthCallback, getStatus, disconnect, refresh } from '../controllers/googleDrive.controller'; + +const router = Router(); + +// Google'ın doğrudan (Bearer token olmadan) yönlendirdiği tek public uç nokta. +router.get('/oauth/callback', oauthCallback); + +router.get('/connect', requireAuth, connect); +router.get('/status', requireAuth, getStatus); +router.post('/disconnect', requireAuth, disconnect); +router.post('/refresh', requireAuth, refresh); + +export default router; diff --git a/src/routes/index.ts b/src/routes/index.ts index 84bf042..8a76c05 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -5,6 +5,7 @@ import precedentRoutes from './precedent.routes'; import caseRoutes from './case.routes'; import templateRoutes from './template.routes'; import draftingRoutes from './drafting.routes'; +import googleDriveRoutes from './googleDrive.routes'; const router = Router(); @@ -17,6 +18,7 @@ router.use('/chat', chatRoutes); router.use('/cases', caseRoutes); router.use('/templates', templateRoutes); router.use('/drafting', draftingRoutes); +router.use('/google-drive', googleDriveRoutes); router.use('/', precedentRoutes); // Keeps the same paths like /api/search-precedents export default router;