// 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). export 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 }; } // PRD §9: local dosya yapısı (dava klasörü → belgeler) birebir Drive'a // yansıtılıyor — dava başlığı = klasör adı. drive.file scope zaten sadece // uygulamanın oluşturduğu klasörleri görebildiği için burada da arama/oluşturma // findOrCreateFolder ile aynı mantıkla çalışıyor. export async function findOrCreateCaseFolder(accessToken: string, casesFolderId: string, caseTitle: string): Promise { return findOrCreateFolder(accessToken, caseTitle, casesFolderId); } interface UploadParams { name: string; parentId: string; mimeType: string; buffer: Buffer; // Verilirse içerik bu mevcut Drive dosyasının YENİ SÜRÜMÜ olarak yüklenir // (Google Drive eski sürümü otomatik revizyon geçmişinde tutar) — yeni bir // kopya dosya OLUŞTURULMAZ. PRD Test 4 "Modified File" tam olarak bu. existingFileId?: string; } // PRD §16: Google Drive'ın resumable upload protokolü kullanılıyor (electron-updater // gibi burada da sıfırdan bir upload mekanizması yazmak yerine Google'ın kendi // resumable session'ı kullanılıyor). Dosya baytları backend'e tek seferde (multer, // bellekte buffer olarak) geldiği için PUT tek parça gönderiliyor — asıl fayda // backend↔Google bacağının bu sayede Google'ın kendi bütünlük/aktarım garantisini // kullanması. Not: Electron↔backend bacağı için bayt-bazlı devam etme (resume) // henüz yok, bkz. PR açıklaması — kuyruk seviyesinde tüm dosya yeniden denenir. export async function uploadFileResumable(accessToken: string, params: UploadParams): Promise<{ id: string; modifiedTime?: string }> { const isUpdate = !!params.existingFileId; const sessionUrl = isUpdate ? `https://www.googleapis.com/upload/drive/v3/files/${params.existingFileId}?uploadType=resumable` : `https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable`; const metadata = isUpdate ? {} : { name: params.name, parents: [params.parentId] }; const sessionRes = await fetch(sessionUrl, { method: isUpdate ? 'PATCH' : 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json; charset=UTF-8', 'X-Upload-Content-Type': params.mimeType, 'X-Upload-Content-Length': String(params.buffer.length), }, body: JSON.stringify(metadata), }); if (!sessionRes.ok) { const err = await sessionRes.json().catch(() => ({})); throw new Error(err.error?.message || 'Google Drive upload oturumu başlatılamadı.'); } const sessionUri = sessionRes.headers.get('location'); if (!sessionUri) { throw new Error('Google Drive upload oturum adresi alınamadı.'); } const uploadRes = await fetch(sessionUri, { method: 'PUT', headers: { 'Content-Type': params.mimeType, 'Content-Length': String(params.buffer.length), }, // Node'un fetch implementasyonu (undici) Buffer'ı runtime'da sorunsuz kabul // ediyor — DOM tip tanımları BodyInit'e Buffer'ı dahil etmediği için sadece // tip seviyesinde cast gerekiyor. body: params.buffer as unknown as BodyInit, }); const data = await uploadRes.json().catch(() => ({})); if (!uploadRes.ok) { throw new Error(data.error?.message || `Google Drive upload başarısız (HTTP ${uploadRes.status}).`); } return data; } // Faz 3 — Restore: bir dosyanın ham baytlarını Drive'dan indirir. Electron'un // Google credential'ı hiç olmadığı için (upload'ta olduğu gibi) bu da backend // üzerinden proxy'leniyor — çağıran taraf (googleDrive.controller.ts) fileId'nin // gerçekten bu kullanıcıya ait olduğunu ÖNCE doğrulamalı. export async function downloadFile(accessToken: string, fileId: string): Promise { const res = await fetch(`${DRIVE_API_URL}/files/${fileId}?alt=media`, { headers: { Authorization: `Bearer ${accessToken}` }, }); if (!res.ok) { throw new Error(`Google Drive dosyası indirilemedi (HTTP ${res.status}).`); } const arrayBuffer = await res.arrayBuffer(); return Buffer.from(arrayBuffer); }