Files
lagos-back/src/lib/googleDriveClient.ts
T
mstfyldzandClaude Sonnet 5 ce6fa9deae feat: Google Drive restore endpoints (Faz 3)
GET /api/google-drive/backup-files: kullanıcının senkronize
edilmiş dosyalarının listesi (PRD §28 "N dosya bulundu"). GET
/download: bir dosyayı Drive'dan proxy'leyip ham baytları döner —
istenen drive_file_id'nin gerçekten bu kullanıcıya ait olduğu
önce doğrulanıyor (PRD §39, başka kullanıcının backup'ı sızmasın).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 04:24:56 +03:00

234 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<TokenResponse> {
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<void> {
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<string> {
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<string> {
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<string> {
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<Buffer> {
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);
}